docs(tests): explain IndexedDB rollback and depot collection scenarios

This commit is contained in:
2026-11-12 18:03:00 +08:00
parent 245f6e3254
commit 60338efe97
4 changed files with 7 additions and 6 deletions
@@ -29,10 +29,11 @@ export async function assetAdoptionStorageCase(assert){
// 已有缓存也不能绕过撤权;失败必须维持同一状态根,随后重新允许以继续历史检查。
allowed=false;const before=engine.getSnapshot();try{await adopt('denied');throw Error('revoked cache adoption succeeded');}catch(error){assert(error.message==='denied','unexpected authorization failure');}assert(engine.getSnapshot()===before,'denied authorization changed Root');allowed=true;
assert(engine.editor.history.undo().status==='applied','adopt undo failed');await engine.settle();assert(engine.editor.history.redo().status==='applied','adopt redo failed');await engine.settle();assert(engine.getSnapshot().document.elements.get('picture').incarnation===2,'redo did not create a fresh incarnation');
// Close every editor. GC must use target's durable manifest rather than an open engine map.
// 关闭所有 editor 后再以源项目 leader 回收;目标项目只能靠已落盘 manifest 继续保护共享字节。
await engine.close();leader=await storage.claim(scope);await storage.commit(leader,{image:image(1),depot:cut([])});const collected=await storage.assetDepot.collect(leader,{authority,now:100,retentionMs:0,limit:128});assert(collected.complete&&collected.pinnedIds.includes('resource')&&!collected.purgedIds.length,'closed destination pins did not protect shared bytes');
// 直接扫描已关闭目标的项目文件表,确认共享资源没有被复制成目标项目本地文件。
const targetLeader=await storage.claim({...scope,projectId:'target'});try{const page=await storage.scanFiles(targetLeader,{expectedRootRevision:(await storage.recover(targetLeader)).rootRevision,limit:128});assert(page.files.length===0,'destination duplicated shared bytes into project files');}finally{await targetLeader.release();}
await leader.release();leader=undefined;await runtime.close();runtime=undefined;
// deleteDatabase 这里只发起清理请求,未等待 success/blocked;场景隔离依靠随机数据库名。
}finally{await leader?.release();await runtime?.close();storage.close();indexedDB.deleteDatabase(name);}
}
+2 -3
View File
@@ -46,13 +46,12 @@ export async function assetDepotStorageCase(assert){
// 摘要/元数据冲突必须整批拒绝,不能推进 rootRevision 或写入部分共享字节。
await rejects(()=>storage.commit(q,{image:image(2),depot:cut([], [{ref:{...ref('one'),digest:'0'.repeat(64)},bytes}])}),'shared_asset_bytes_changed');assert((await storage.recover(q)).rootRevision===1,'invalid bytes advanced Root');
await rejects(()=>storage.commit(q,{image:image(2),depot:cut([['one',['element:q']]], [{ref:{...ref('one'),asset:{...ref('one').asset,width:3}},bytes}])}),'shared_asset_identity_conflict');assert(await count()===0&&(await storage.recover(q)).rootRevision===1,'identity conflict partially committed bytes or Root');
// Force a later files-store conflict after the depot puts: the whole transaction must roll back.
// 先让 depot 排入写入,再由 files 的重复身份触发失败;检查整笔 IDB 事务回滚,而非只测发送前校验拒绝。
await storage.commit(q,{image:image(2),files:[{id:'existing',bytes}],depot:cut()});
await rejects(()=>storage.commit(q,{image:image(3),files:[{id:'existing',bytes}],depot:cut([['one',['element:q']]], [{ref:ref('one'),bytes}])}),'resource_identity_conflict');assert(await count()===0&&(await storage.recover(q)).rootRevision===2,'late failure committed depot or Root');
await storage.commit(q,{image:image(3),depot:cut([['one',['element:q']]], [{ref:ref('one'),bytes}])});assert(await count()===1&&(await storage.assetDepot.read(q,authority,'one')).purgedAt===undefined,'authorized rehydration failed');
// A one-item sweep must provide a cursor and preserve each resource decision.
// limit=1 强制分两页;nextAfter 是资源身份游标,第二页结束才表示这一轮扫描完成。
const first=await collect(q,130,{limit:1});assert(first.nextAfter==='one','bounded sweep cursor missing');assert((await collect(q,130,{after:first.nextAfter,limit:1})).nextAfter===undefined,'bounded sweep did not finish');
// Exercise the actual engine persistence entry point, rather than only hand-built batches.
// 从手工 batch 检查切换到真实引擎:stageFiles、确认上传及关闭后,再检查物理文件行。
const projectId='engine-project',registry=createRegistry(builtinComponents());let document=createDocument(projectId,[{id:'canvas',parent:null}],[]),version=1,counter=0,resource,uploads=0;
// 模拟上传回执及 authority;此场景不应触发已有资源接纳,上传返回原始字节的服务端身份。
@@ -26,6 +26,7 @@ export async function blobCheckpointCase(assert){
const write=async value=>{const tx=raw.transaction('checkpoints','readwrite'),terminal=done(tx);tx.objectStore('checkpoints').put(value,[key,1]);await terminal;};
// 分别破坏字节、header、编码版本和 UTF-8;每次恢复检查对应错误,并还原原行供下一场景继续。
for(const [kind,expected]of [['bytes','recovery_checksum_mismatch'],['header','recovery_checksum_mismatch'],['future','recovery_checkpoint_interpreter_unavailable'],['utf8','corrupt_recovery_image']]){
// utf8 样本重新计算正确 checksum,隔离“摘要匹配但正文不可解码”;其余样本分别隔离字节/header/解释器错误。
const damaged=structuredClone(saved);if(kind==='bytes')damaged.bytes=new Blob([JSON.stringify(image(9))]);else if(kind==='header')damaged.header.formatVersion=2;else if(kind==='future')damaged.checkpointVersion=2;else{damaged.bytes=new Blob([new Uint8Array([123,34,120,34,58,34,255,34,125])]);damaged.checksum=await hash(damaged.bytes);}
await write(damaged);let code;try{await storage.recover(leader);}catch(error){code=error.code;}assert(code===expected,`${kind} cut was accepted: ${code}`);if(kind==='future'){code=undefined;try{await storage.commit(leader,{image:image(2)});}catch(error){code=error.code;}assert(code===expected,'writer replaced a future checkpoint');}await write(saved);
}
+2 -2
View File
@@ -27,7 +27,7 @@ export async function depotFilesCase(assert){
p=await storage.claim(scope);q=await storage.claim({...scope,projectId:'q'});raw=await openRaw();assert(raw.version===5,'pointer storage did not upgrade to DB v5');
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.
// 第一行已安排从 Blob 转指针,再让第二行 MIME 不符触发失败;原 Blob、摘要行数和 Root 应一起回到原状态。
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');
// 所有字段合法时将两个项目 Blob 提升为同摘要字节及独立指针,随后仍按各自原始 MIME 读回。
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');}
@@ -73,7 +73,7 @@ export async function depotAdoptionRaceCase(assert){
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();
// 先等待旧无引用 batch 进入屏障,再进行 GC;这控制了可重现的竞态顺序,避免靠时间猜测。
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.
// 旧 commit 仍停在包装器的 gate,尚未开启 IDB 事务;持久 manifest 已无引用,所以 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');
// GC 已真实删除字节后,新接纳通过 authority.read 补回;释放旧 commit 后检查新字节和新 manifest 均保留。
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');