Deduplicate stored upload files and retain adoption bytes until their Root is durable

This commit is contained in:
2026-11-09 16:31:31 +08:00
parent a626e6c300
commit 902e30a65b
10 changed files with 167 additions and 18 deletions
+12
View File
@@ -55,3 +55,15 @@ test('WorkspaceRuntime owns one depot across projects and closes it after projec
assert.deepEqual(runtime.assetDepot.scope,{accountId,workspaceId});assert.equal(Object.isFrozen(f.storage),false);
const p=await runtime.open(createDocument('p',[{id:'canvas',parent:null}],[]),new Map([['canvas',1]]));await adopt(p);await p.settle();const q=await runtime.open(createDocument('q',[{id:'canvas',parent:null}],[]),new Map([['canvas',1]]));await adopt(q);await q.settle();assert.equal(runtime.assetDepot.getResources().size,1);assert.equal(f.calls.filter(v=>v==='read').length,1);await runtime.close();assert.equal(f.closed,true);assert.equal(runtime.assetDepot.getResources().size,0);
});
test('an older Root cannot release newly adopted bytes while its unpinned manifest is committing',async()=>{
const f=fixture(),engine=await f.open('p',{historyLimit:1});let release,started;const commit=f.storage.commit;
try{
await adopt(engine);await engine.settle();engine.editor.dispatch({type:'element.remove',target:{id:'picture',incarnation:1}});await engine.settle();engine.editor.dispatch({type:'element.create',element:{...node({id:'unused',src:'unused'},'text'),componentId:'text',data:{title:'text',text:''}}});await engine.settle();
const ready=new Promise(resolve=>started=resolve),gate=new Promise(resolve=>release=resolve);let once=true;
f.storage.commit=async(leader,batch)=>{if(once){once=false;assert.equal(batch.depot.pins.some(([id])=>id==='shared'),false);started();await gate;}return commit(leader,batch);};
engine.editor.updateRuntime(previous=>({...previous}));await ready;
// GC is allowed here: the old Root has no working/history/ACK/job reference.
f.cache.clear();assert.equal((await adopt(engine,'fresh')).status,'applied');release();await engine.settle();assert.equal(await f.cache.values().next().value.bytes.text(),'original bytes');assert.equal(f.roots.get('p').payload.document.elements.some(element=>element.id==='fresh'),true);
}finally{release?.();f.storage.commit=commit;await engine.close();}
});
@@ -45,6 +45,7 @@ export async function assetDepotStorageCase(assert){
const staged=engine.stageFiles([{name:'source.png',bytes}],([handle],tx)=>{resource=handle.id;tx.dispatch({type:'element.create',element:{id:'picture',incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:'canvas',geometry:{x:0,y:0,width:2,height:1,rotation:0},data:{title:'picture',text:'',assetId:handle.id,src:handle.src}}});});assert(staged.status!=='rejected','engine image staging failed');
let ready=false;for(let i=0;i<200;i++){if(engine.getSnapshot().runtime.resources.get(resource)?.stored){ready=true;break;}await new Promise(resolve=>setTimeout(resolve,5));}assert(ready,'engine upload did not settle');
}finally{await engine.close();}
const physical=await new Promise((resolve,reject)=>{const request=database.transaction('files').objectStore('files').get([JSON.stringify([scope.accountId,scope.workspaceId,projectId]),resource]);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});assert(physical?.fileVersion===1&&!(physical instanceof Blob),'actual engine upload retained duplicate project bytes');
const promoted=await storage.assetDepot.read(q,authority,resource);assert(promoted?.bytes&&await promoted.bytes.text()==='shared original'&&uploads===1,'engine did not promote acknowledged upload bytes');
assert((await collect(q,200)).pinnedIds.includes(resource),'closed engine did not retain durable document/history pins');
await q.release();await rejects(()=>collect(q,200),'storage_leader_lost');database.close();
+4
View File
@@ -1,3 +1,4 @@
import {depotFilesCase,depotFilesUpgradeCase,depotAdoptionRaceCase} from './depot-files-browser-cases.mjs';
import {assetAdoptionStorageCase} from './asset-adoption-browser-cases.mjs';
import {assetDepotStorageCase} from './asset-depot-browser-cases.mjs';
import {createBrowserImageDecoder} from '../../src/lib/canvas-runtime/browser-images.ts';
@@ -739,6 +740,9 @@ export async function run() {
const rejects = async (callback, message) => { try { await callback(); } catch (error) { assert(error.message === message, `expected ${message}, received ${error.message}`); return; } throw new Error(`expected ${message}`); };
let leader, other;
try {
await depotAdoptionRaceCase(assert);results.push('real IndexedDB GC and a gated older Root cannot consume or release bytes belonging to a newer adoption cut');
await depotFilesCase(assert);results.push('stored uploads use shared-byte file pointers with original MIME, atomic promotion rollback, logical inventory and independent identity eviction/rehydration');
await depotFilesUpgradeCase(assert);results.push('DB v3 original files/checkpoints upgrade to v4 and lazily promote through an idempotent Root cut');
await assetAdoptionStorageCase(assert);results.push('cross-project adoption uses fresh authorization, survives workspace/IndexedDB reopening and undo/redo, and preserves closed destination pins without project-local byte copies');
await assetDepotStorageCase(assert);results.push('workspace depot atomically shares digest bytes, preserves closed-project pins, blocks unknown/stale coverage and rolls back failed Root cuts');
leader = await storage.claim(scope);
@@ -0,0 +1,51 @@
import {createBrowserStorage} from '../../src/lib/canvas-runtime/storage.ts';
export async function depotFilesCase(assert){
const name=`depot-files-${crypto.randomUUID()}`,scope={accountId:'a',workspaceId:'w',projectId:'p'},authority='https://assets.test',a=new Blob(['shared bytes'],{type:'image/jpeg'}),b=new Blob(['shared bytes'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await a.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join(''),storage=createBrowserStorage({name});let p,q,raw;
const ref=id=>({version:1,authority,digest,asset:{id,url:`${authority}/${id}`,width:2,height:1,bytes:a.size,mime_type:'image/png'}}),resource=(id,mimeType,stored=true)=>({id,operationId:`upload-${id}`,digest,byteLength:a.size,mimeType,...(stored?{stored:ref(id).asset}:{})}),image=(n,resources)=>({formatVersion:1,rootRevision:n,localRevision:n,payload:{runtime:{resources}}}),cut=(pins=[],writes=[])=>({version:1,authority,complete:true,pins,writes});
const key=JSON.stringify(['a','w','p']),rows=[['one',resource('one',a.type)],['two',resource('two',b.type)]],pins=[['one',['element:one']],['two',['element:two']]],writes=[{ref:ref('one'),bytes:a},{ref:ref('two'),bytes:b}];
const openRaw=()=>new Promise((resolve,reject)=>{const request=indexedDB.open(name);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
const get=(store,key)=>new Promise((resolve,reject)=>{const tx=raw.transaction(store),request=tx.objectStore(store).get(key);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
const count=store=>new Promise((resolve,reject)=>{const tx=raw.transaction(store),request=tx.objectStore(store).count();request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
const put=(store,key,value)=>new Promise((resolve,reject)=>{const tx=raw.transaction(store,'readwrite');tx.objectStore(store).put(value,key);tx.oncomplete=resolve;tx.onabort=()=>reject(tx.error);});
const rejects=async(fn,code)=>{try{await fn();}catch(error){assert(error.code===code,`expected ${code}, got ${error.code}`);return;}throw Error(`expected ${code}`);};
const collect=now=>storage.assetDepot.collect(p,{authority,now,retentionMs:10,limit:128});
try{
p=await storage.claim(scope);q=await storage.claim({...scope,projectId:'q'});raw=await openRaw();assert(raw.version===4,'pointer storage did not upgrade to DB v4');
await storage.commit(p,{image:image(0,[['one',resource('one',a.type,false)],['two',resource('two',b.type,false)]]),files:[{id:'one',bytes:a},{id:'two',bytes:b}],depot:cut(pins)});
assert(await get('files',[key,'one']) instanceof Blob,'unconfirmed upload lost its original bytes');
// The first pointer is converted before the second row fails. Everything must roll back.
await rejects(()=>storage.commit(p,{image:image(1,[rows[0],['two',resource('two','image/webp')]]),depot:cut(pins,writes)}),'shared_asset_bytes_changed');assert(await get('files',[key,'one']) instanceof Blob&&await count('asset-bytes')===0&&(await storage.recover(p)).rootRevision===0,'failed promotion partially converted files/bytes/Root');
await storage.commit(p,{image:image(1,rows),depot:cut(pins,writes)});assert(await count('asset-bytes')===1,'same digest occupied multiple shared byte entries');for(const id of ['one','two']){const local=await get('files',[key,id]);assert(!(local instanceof Blob)&&local.fileVersion===1&&local.digest===digest,'stored upload still retained a project-local Blob');}
assert((await storage.readFile(p,'one')).type===a.type&&(await storage.readFile(p,'two')).type===b.type,'pointer read changed the original File MIME');assert(await(await storage.readFile(p,'one')).text()==='shared bytes','pointer did not resolve original bytes');
const inventory=await storage.scanFiles(p,{expectedRootRevision:1,limit:128});assert(inventory.files.length===2&&inventory.files[0].mimeType===a.type&&inventory.files.every(row=>row.byteLength===a.size),'inventory did not expose logical file metadata');
await storage.commit(q,{image:image(0,[]),depot:cut([['one',['history:q']]])});await storage.commit(p,{image:image(2,rows),depot:cut()});await collect(0);await collect(10);assert(await count('asset-bytes')===1,'purging one ResourceId deleted another ResourceId bytes');assert(await storage.readFile(p,'two')===undefined,'purged identity resolved through another live digest owner');assert((await storage.scanFiles(p,{expectedRootRevision:2,limit:128})).files.length===1,'inventory advertised an evicted pointer as readable bytes');
await storage.commit(q,{image:image(1,[]),depot:cut()});await collect(10);await collect(20);assert(await count('asset-bytes')===0&&await storage.readFile(p,'one')===undefined,'last unreferenced digest was retained');
await storage.commit(p,{image:image(3,rows),depot:cut([['one',['element:one']]],[writes[0]])});assert(await count('asset-bytes')===1&&await(await storage.readFile(p,'one')).text()==='shared bytes','same identity could not rehydrate a file pointer');assert(await storage.readFile(p,'two')===undefined,'rehydrating one identity revived another purged identity');
const pointer=await get('files',[key,'one']);await put('files',[key,'one'],{...pointer,fileVersion:2});await rejects(()=>storage.readFile(p,'one'),'file_interpreter_unavailable');await rejects(()=>storage.scanFiles(p,{expectedRootRevision:3,limit:128}),'file_interpreter_unavailable');await put('files',[key,'one'],{...pointer,digest:'0'.repeat(64)});await rejects(()=>storage.readFile(p,'one'),'corrupt_file_store');await put('files',[key,'one'],pointer);
const old=await new Promise(resolve=>{const request=indexedDB.open(name,3);request.onerror=event=>{event.preventDefault();resolve(request.error.name);};request.onsuccess=()=>{request.result.close();resolve('accepted');};});assert(old==='VersionError','DB v3 reader accepted pointer file rows');
}finally{raw?.close();await p?.release();await q?.release();storage.close();indexedDB.deleteDatabase(name);}
}
export async function depotFilesUpgradeCase(assert){
const name=`depot-upgrade-${crypto.randomUUID()}`,scope={accountId:'a',workspaceId:'w',projectId:'p'},key=JSON.stringify(['a','w','p']),authority='https://assets.test',bytes=new Blob(['old bytes'],{type:'image/png'}),hex=buffer=>[...new Uint8Array(buffer)].map(n=>n.toString(16).padStart(2,'0')).join(''),digest=hex(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer())),asset={id:'old',url:`${authority}/old`,width:2,height:1,bytes:bytes.size,mime_type:bytes.type},image={formatVersion:1,rootRevision:0,localRevision:0,payload:{runtime:{resources:[['old',{id:'old',digest,byteLength:bytes.size,mimeType:bytes.type,stored:asset}]]}}},checksum=hex(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify(image))));
let raw=await new Promise((resolve,reject)=>{const request=indexedDB.open(name,3);request.onupgradeneeded=()=>{for(const store of ['metadata','checkpoints','journal','files','asset-depot','asset-bytes','asset-pins'])request.result.createObjectStore(store);};request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
await new Promise((resolve,reject)=>{const tx=raw.transaction(['metadata','checkpoints','files'],'readwrite');tx.objectStore('metadata').put({fence:1,throughRevision:0,checkpointRevision:0},key);tx.objectStore('checkpoints').put({image,checksum},[key,0]);tx.objectStore('files').put(bytes,[key,'old']);tx.oncomplete=resolve;tx.onabort=()=>reject(tx.error);});raw.close();const storage=createBrowserStorage({name});let leader;
try{leader=await storage.claim(scope);assert((await storage.recover(leader)).rootRevision===0,'v3 checkpoint lost during upgrade');await storage.commit(leader,{image,depot:{version:1,authority,complete:true,pins:[['old',['element:old']]],writes:[{ref:{version:1,authority,digest,asset},bytes}]}});assert(await(await storage.readFile(leader,'old')).text()==='old bytes','idempotent reindex lost old bytes');raw=await new Promise((resolve,reject)=>{const request=indexedDB.open(name);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});const row=await new Promise((resolve,reject)=>{const request=raw.transaction('files').objectStore('files').get([key,'old']);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});assert(raw.version===4&&row.fileVersion===1&&!(row instanceof Blob),'old project was not lazily promoted');raw.close();}finally{await leader?.release();storage.close();indexedDB.deleteDatabase(name);}
}
export async function depotAdoptionRaceCase(assert){
const {openProjectEngine}=await import('../../src/lib/canvas-runtime/engine.ts'),{createDocument,createRegistry,builtinComponents,applyChanges}=await import('../../src/lib/canvas-core/index.ts');
const name=`depot-race-${crypto.randomUUID()}`,base=createBrowserStorage({name}),scope={accountId:'a',workspaceId:'w',projectId:'source'},authority='https://assets.test',bytes=new Blob(['race bytes'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join(''),ref={version:1,authority,digest,asset:{id:'shared',url:`${authority}/shared`,width:2,height:1,bytes:bytes.size,mime_type:bytes.type}};let leader,engine,release,started,gate,blocked=false,version=1,hydrations=0;const receipts=new Map();
let document=createDocument('target',[{id:'canvas',parent:null}],[]);const registry=createRegistry(builtinComponents());
const storage={...base,async commit(held,batch){if(gate&&!blocked){blocked=true;assert(!batch.depot.pins.some(([id])=>id==='shared'),'older Root was not unpinned');started();await gate;}return base.commit(held,batch);}};
const assets={authority:{scope:authority,async resolve(){return ref;},async read(){hydrations++;return bytes;}},sourceURL:id=>`${authority}/${id}`,displayURL:id=>`/assets/${id}`,async upload(){throw Error('unexpected upload');},async query(){throw Error('unexpected upload query');},accept(){throw Error('unexpected upload receipt');}},persistence={compile(intent,id,snapshot){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request){document=applyChanges(document,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document,versions:new Map([['canvas',++version]])});return{outcome:'applied'};},async query(){return{outcome:'unresolved'};},canonical:request=>receipts.get(request.id)};
const picture=(handle,id)=>({id,incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:'canvas',geometry:{x:0,y:0,width:2,height:1,rotation:0},data:{title:id,text:'',assetId:handle.id,src:handle.src}}),adopt=id=>engine.adoptResource('shared',(handle,tx)=>tx.dispatch({type:'element.create',element:picture(handle,id)}));
try{
leader=await storage.claim(scope);await storage.commit(leader,{image:{formatVersion:1,rootRevision:0,localRevision:0,payload:null},depot:{version:1,authority,complete:true,pins:[],writes:[{ref,bytes}]}});
engine=await openProjectEngine({scope:{...scope,projectId:'target'},registry,document,versions:new Map([['canvas',1]]),storage,assets,persistence,historyLimit:1});assert((await adopt('old')).status==='applied','initial adoption failed');await engine.settle();engine.editor.dispatch({type:'element.remove',target:{id:'old',incarnation:1}});await engine.settle();engine.editor.dispatch({type:'element.create',element:{...picture({id:'unused',src:'unused'},'text'),componentId:'text',data:{title:'text',text:''}}});await engine.settle();
const ready=new Promise(resolve=>started=resolve);gate=new Promise(resolve=>release=resolve);engine.editor.updateRuntime(previous=>({...previous}));await ready;
// Old commit has not acquired IDB yet. A complete unpinned manifest permits GC.
await storage.assetDepot.collect(leader,{authority,now:0,retentionMs:0,limit:128});const purged=await storage.assetDepot.collect(leader,{authority,now:0,retentionMs:0,limit:128});assert(purged.purgedIds.includes('shared'),'GC could not retire the actually unreferenced identity');assert(!(await storage.assetDepot.read(leader,authority,'shared')).bytes,'GC left bytes before rehydration');
assert((await adopt('new')).status==='applied'&&hydrations===1,'fresh adoption did not rehydrate original bytes');release();await engine.settle();const restored=await storage.assetDepot.read(leader,authority,'shared');assert(restored.bytes&&await restored.bytes.text()==='race bytes','older Root released the new adoption bytes before the matching durable cut');const stillPinned=await storage.assetDepot.collect(leader,{authority,now:1,retentionMs:0,limit:128});assert(stillPinned.pinnedIds.includes('shared'),'new Root did not atomically persist resource pins');
}finally{release?.();await engine?.close();await leader?.release();storage.close();indexedDB.deleteDatabase(name);}
}