docs(tests): explain durable recovery cuts and replay ownership

This commit is contained in:
2026-11-12 17:33:12 +08:00
parent 19bfbc772d
commit 2fa8b88f2c
6 changed files with 12 additions and 0 deletions
+2
View File
@@ -43,6 +43,7 @@ async function applied(f){const engine=await f.open();engine.editor.dispatch({ty
// 远端又修改几何后,恢复以较新的完整读取接纳已成功请求;保留原历史与冻结字节,重开后也不增加 POST。
test('a verified applied request adopts a newer full read without reapplying its old receipt or generating a new write',async()=>{
const f=fixture(),{engine,request}=await applied(f);f.remoteGeometry({x:500,width:300});const history=engine.getSnapshot().history,session=await engine.prepareRecovery();
// prepareRecovery 只形成待确认预览:认可成功证据,但原请求仍 waiting;apply 后才接纳完整读并推进基线。
assert.deepEqual(session.acknowledgedRequestIds,[request.id]);assert.deepEqual(session.pendingOperationIds,[]);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'waiting');
await session.apply();await engine.settle();const current=engine.getSnapshot();assert.equal(current.history,history);assert.equal(current.document.elements.get('a').data.title,'first');assert.equal(current.document.elements.get('a').geometry.x,500);assert.equal(current.document.elements.get('a').geometry.width,300);
assert.equal(current.runtime.versions.get(root),3);assert.equal(current.runtime.ledger.requests.get(request.id).phase,'resolved');assert.equal(current.runtime.ledger.requests.get(request.id).body,request.body);assert.equal(f.calls.filter(call=>call.method==='POST').length,1);
@@ -54,6 +55,7 @@ test('acknowledgement and explicit replan commit together; original writer relea
const f=fixture(),{engine,request}=await applied(f);const edit=engine.editor.dispatch({type:'element.geometry',target,patch:{x:200}});await engine.settle();f.remoteGeometry({x:500,width:300});
const session=await engine.prepareRecovery();assert.ok(session.conflict);assert.deepEqual(session.pendingOperationIds,edit.operationIds);const plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));
assert.equal(plan.audit.decisions.length,1);assert.equal(plan.ledger.operations.get(request.operationIds[0]).resolution,'applied');f.failCodec=false;f.broken=true;await assert.rejects(session.resolve(plan),/quota/);assert.equal(f.calls.filter(call=>call.method==='POST').length,1);
// 重试同一恢复计划,先确保持久切点成功,再允许原 writer 之后的本地操作继续发送。
f.broken=false;await session.resolve(plan);await engine.settle();assert.equal(f.calls.filter(call=>call.method==='POST').length,2);assert.equal(engine.getSnapshot().document.elements.get('a').geometry.width,300);
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').geometry.x,500);assert.equal(engine.getSnapshot().document.elements.get('a').geometry.width,300);
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'a');
+2
View File
@@ -41,6 +41,7 @@ test('each durable crash cut restores its own document/history/ledger and extern
const producer=fixture(),engine=await producer.open();await engine.persist();const initial=structuredClone(producer.cuts.at(-1));
const edited=engine.editor.dispatch({type:'element.data',target,patch:{title:'Changed'}});assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'Changed');assert.ok(engine.getDurability().rootRevision>engine.getDurability().durableRootRevision);
await producer.drain(engine);const decoded=producer.cuts.map(image=>({image,state:decodeRecovery(image,registry)})),request=[...engine.getSnapshot().runtime.ledger.requests.values()][0],receipt=producer.receipts.get(request.id),changed=producer.server();
// 在真正捕获的 commit 序列中按 ledger 状态定位切点,不手工拼造这些镜像;每个恢复环境仍使用内存存储。
const pending=decoded.find(({state})=>state.runtime.ledger.operations.size===1&&state.runtime.ledger.requests.size===0),frozen=decoded.find(({state})=>state.runtime.ledger.requests.get(request.id)?.phase==='frozen'),dispatch=decoded.find(({state})=>state.runtime.ledger.requests.get(request.id)?.phase==='dispatch-marked'),resolved=decoded.findLast(({state})=>state.runtime.ledger.requests.get(request.id)?.phase==='resolved');
assert.ok(pending&&frozen&&dispatch&&resolved,'producer did not expose every required durable transition');await engine.close();
// 初始镜像没有尚未落盘的可见编辑;后续每个场景都明确指定应查询/写入的次数及服务端是否已执行。
@@ -56,6 +57,7 @@ test('each durable crash cut restores its own document/history/ledger and extern
// 本地 dispatch 切点可对应服务执行前或执行后;若服务已经提交,不能因读取旧镜像就把服务状态回退。
const f=fixture(scenario.image,scenario.server,scenario.receipt),recovered=await f.open();
try{
// 收敛后同时核对客户端、假服务、历史及请求次数,避免只恢复 UI 内容却重复执行外部操作。
await f.drain(recovered);const state=recovered.getSnapshot();assert.equal(state.document.elements.get('a').data.title,scenario.edited?'Changed':'Original');assert.equal(f.server().elements.get('a').data.title,scenario.edited?'Changed':'Original');assert.equal(state.history.undo.length,scenario.edited?1:0);assert.equal(state.operations.size,scenario.edited?1:0);
assert.equal(f.calls.filter(call=>call.method==='POST').length,scenario.posts);assert.equal(f.calls.filter(call=>call.method==='GET').length,scenario.queries);
if(scenario.edited)assert.ok(state.runtime.ledger.operations.has(edited.operationIds[0]));
+2
View File
@@ -18,6 +18,8 @@ async function fixture(){let broken=false;const errors=[],engine=await openProje
// 全部网络入口都故意抛错,保证测试只观察本地元数据而不是悄悄依赖网络同步。
persistence:{compile(){throw Error('unexpected write');},async submit(){throw Error('unexpected write');},async query(){throw Error('unexpected query');},canonical(){throw Error('unexpected canonical');}},onError:error=>errors.push(error)});await engine.persist();return{engine,errors,fail:value=>broken=value};}
// runtime 修改先通知脏修订,保存后通知对应持久化修订;关闭也通知进度,但完成保存不再增加一次业务 Root。关闭后订阅为空清理函数。
// updateRuntime 即使只返回内容相同的新对象也会形成元数据根;revision 在 persist 前捕获,用于排除保存过程额外发布根。
test('durability subscribers observe dirty metadata, final durable cut and closure without another business Root',async()=>{const{engine}=await fixture(),states=[];const stop=engine.subscribeDurability(()=>states.push(engine.getDurability()));assert.strictEqual(engine.getDurability(),engine.getDurability());engine.editor.updateRuntime(previous=>({...previous}));const revision=engine.getSnapshot().rootRevision;assert.ok(states.some(state=>state.rootRevision>state.durableRootRevision));await engine.persist();assert.equal(states.at(-1).durableRootRevision,revision);assert.equal(engine.getSnapshot().rootRevision,revision);await engine.close();assert.ok(states.some(state=>state.closing&&!state.closed));assert.equal(states.at(-1).closed,true);stop();const noop=engine.subscribeDurability(()=>assert.fail('closed owner notified'));noop();});
// 一个监听器抛错不妨碍其他监听器看到 quota;数字 DOM 风格错误归一成字符串,保存失败保持脏状态,重试成功后清除错误。
// broken 只让内存 commit 抛错,不制造真实磁盘配额不足;errors 数组单独核对观察者错误的隔离通路。
test('storage failures remain observable and normalize numeric DOM-style codes without listener exceptions changing durability',async()=>{const f=await fixture();const states=[];f.engine.subscribeDurability(()=>{throw Error('observer');});f.engine.subscribeDurability(()=>states.push(f.engine.getDurability()));f.fail(true);f.engine.editor.updateRuntime(previous=>({...previous}));await assert.rejects(f.engine.persist());assert.equal(states.at(-1).error,'quota');assert.equal(typeof states.at(-1).error,'string');assert.ok(states.at(-1).durableRootRevision<states.at(-1).rootRevision);f.fail(false);await f.engine.persist();assert.equal(states.at(-1).error,undefined);assert.equal(states.at(-1).rootRevision,states.at(-1).durableRootRevision);assert.ok(f.errors.some(error=>error.message==='observer'));await f.engine.close();});
+2
View File
@@ -27,6 +27,7 @@ test('pending replay preserves sequential field baselines and skips a prefix alr
test('replay distinguishes missing and null fields while applying and skipping repeated deletes',()=>{
const original=base(),insert={kind:'field',target:{id:'a',incarnation:1},field:'data.optional',before:null,after:null,beforePresent:false,afterPresent:true},remove={...insert,beforePresent:true,afterPresent:false};
const added=replayPending(original,ledgerFor([[insert]]),registry);assert.ok(Object.hasOwn(added.elements.get('a').data,'optional'));assert.equal(added.elements.get('a').data.optional,null);
// 删除后重放同一删除应保持缺失;beforePresent/afterPresent 才能区分“没有字段”和“字段值为 null”。
const removed=replayPending(original,ledgerFor([[insert],[remove],[remove]]),registry);assert.equal(Object.hasOwn(removed.elements.get('a').data,'optional'),false);
});
// 先迁移 owner 再修改子 canvas 内字段;即使值未变化,错误作用域和旧 incarnation 仍必须被拒绝。
@@ -42,6 +43,7 @@ test('late conflicts discard the entire private replay draft and preserve the AC
const original=base(),ledger=ledgerFor([[title('original','first')],[title('wrong','last')]]),before=JSON.stringify([...ledger.operations]);assert.throws(()=>replayPending(original,ledger,registry),/field_conflict/);assert.equal(original.elements.get('a').data.title,'original');assert.equal(JSON.stringify([...ledger.operations]),before);assert.equal(replayPending(original,ledgerFor([[title('original','first')],[title('first','last')]]),registry).elements.get('a').data.title,'last');
});
// 256 次创建后再删除并以新 incarnation 恢复一个对象,对比逐组 applyChanges 的元素和墓碑结果。
// 标题/成员数量不足以验证生命周期,因此还断言墓碑保存被删除的第一代,而当前记录是第二代。
test('large replay preserves create/delete/restore lifecycles and conditional failures across operation boundaries',()=>{
const original=base(),created=Array.from({length:256},(_,i)=>node(`new-${i}`)),create=created.map(after=>({kind:'element',id:after.id,before:null,after})),deleted=created[50],restored={...deleted,incarnation:2,data:{...deleted.data,title:'restored'}},groups=[create,[{kind:'element',id:deleted.id,before:deleted,after:null}],[{kind:'element',id:deleted.id,before:null,after:restored}]];
const result=replayPending(original,ledgerFor(groups),registry);let reference=original;for(const changes of groups)reference=applyChanges(reference,changes);assert.deepEqual([...result.elements],[...reference.elements]);assert.deepEqual([...result.tombstones],[...reference.tombstones]);assert.equal(result.tombstones.get(deleted.id),1);assert.equal(original.elements.size,1);
+2
View File
@@ -10,6 +10,7 @@ import {serializeRecoveryImage} from '../../src/lib/canvas-runtime/recovery-json
const options=()=>{let clock=0,yields=0;return{value:{now:()=>clock+=10,yieldTask:async()=>{yields++;}},get yields(){return yields;}};};
// 覆盖跨块 emoji、孤立代理字符、转义、整数键顺序、危险名称、非有限数和稀疏数组,对照原生字符串及字节数。
test('chunked recovery JSON preserves native byte order, Unicode boundaries, escaping and scalar normalization',async()=>{
// 4095 个 ASCII 字符后接代理对/孤立代理字符,故意把 Unicode 边界放到分块附近;整数键反序插入检查原生输出顺序。
const values=['a'.repeat(4095)+'👀'+'\n\t"\\'.repeat(4000),'a'.repeat(4095)+'\ud800x\udc00','\u2028\u2029中文'.repeat(5000)],properties=Object.fromEntries(Array.from({length:1800},(_,i)=>[String(1800-i),i%3?values[i%values.length].slice(0,20):null]));Object.defineProperty(properties,'__proto__',{value:{keep:true},enumerable:true});
const image=sealRecoveryImage(17,8,5,{values,properties,scalars:[NaN,Infinity,-0,null,true,false],sparse:Array(3)}),scheduling=options(),blob=await serializeRecoveryImage(image,scheduling.value);assert.equal(await blob.text(),JSON.stringify(image));assert.equal(blob.size,new TextEncoder().encode(JSON.stringify(image)).byteLength);assert.equal(blob.size,sealedRecoveryByteLength(image));assert.ok(scheduling.yields>0);assert.deepEqual(JSON.parse(await blob.text()),JSON.parse(JSON.stringify(image)));
});
@@ -21,5 +22,6 @@ test('small owned subtree reuse does not reread caller getters or infer ownershi
// 粗时钟下大 image 仍须让出;check 检测 owner 丢失或 yieldTask 抛错时,以原错误拒绝而非返回部分 Blob。
test('coarse clocks yield for large images and a lost owner or host failure cannot return partial bytes',async()=>{
const image=sealRecoveryImage(1,1,1,{rows:Array.from({length:14000},(_,i)=>({id:i,text:'x'.repeat(100)}))});let yields=0;const blob=await serializeRecoveryImage(image,{now:()=>0,yieldTask:async()=>{yields++;}});assert.ok(yields>0);assert.equal(await blob.text(),JSON.stringify(image));
// check 与 yieldTask 使用同一错误对象分别模拟 owner 失效和宿主调度失败,拒绝必须保留原对象身份。
const failure=Error('owner lost');let gone=false,clock=0;await assert.rejects(serializeRecoveryImage(image,{now:()=>clock+=10,yieldTask:async()=>{gone=true;},check(){if(gone)throw failure;}}),error=>error===failure);clock=0;await assert.rejects(serializeRecoveryImage(image,{now:()=>clock+=10,yieldTask:async()=>{throw failure;}}),error=>error===failure);
});
+2
View File
@@ -16,6 +16,7 @@ test('metadata-only roots reuse the document encoding and preserve prior images
const document=doc(),one=encodeRecovery(snapshot(document));let two;const values=Map.prototype.values;try{Map.prototype.values=function(){if(this.size===2)throw Error('metadata root enumerated document records');return values.call(this);};two=encodeRecovery(snapshot(document,1));}finally{Map.prototype.values=values;}assert.strictEqual(one.payload.document,two.payload.document);assert.strictEqual(one.payload.document,one.payload.runtime.acknowledged);const changed=applyChanges(document,[{kind:'field',target:{id:'A',incarnation:1},field:'data.text',before:'',after:'new'}]),three=encodeRecovery(snapshot(changed,2));assert.equal(three.payload.document.elements[0].data.text,'new');assert.equal(one.payload.document.elements[0].data.text,'');assert.strictEqual(three.payload.document.elements[1],one.payload.document.elements[1]);assert.strictEqual(three.payload.document,encodeRecovery(snapshot(changed,3)).payload.document);
});
// 外部 envelope 即使冻结,内部 Map 成员与元素数据仍可能变;每次编码重新捕获,既不冻结调用者也不污染旧 image。
// lookalike 只复制包络再冻结,内容虽然来自框架文档,也不能继承原包络私有的所有权标记。
test('unowned document envelopes reread mutable map membership and nested data without freezing callers',()=>{
const base=doc(),mutable=node('mutable'),elements=new Map([['mutable',mutable]]),document=Object.freeze({...base,elements}),one=encodeRecovery(snapshot(document));mutable.data.text='changed';elements.set('other',node('other'));const two=encodeRecovery(snapshot(document,1));assert.equal(two.payload.document.elements.length,2);assert.equal(two.payload.document.elements[0].data.text,'changed');assert.equal(one.payload.document.elements.length,1);assert.equal(one.payload.document.elements[0].data.text,'');assert.equal(Object.isFrozen(mutable),false);const lookalike=Object.freeze({...base});assert.notStrictEqual(encodeRecovery(snapshot(lookalike)).payload.document,encodeRecovery(snapshot(lookalike,1)).payload.document);
});
@@ -37,5 +38,6 @@ test('metadata roots reuse owned table entry encodings while new tables retain o
// 冻结外部 Map 不冻结成员或阻止 set;内部表里的浅冻结值也可能含可变子项,两种情况都必须重读并保留旧快照。
test('external maps and immutable-looking maps reread membership; owned tables still reread mutable nested values',()=>{
const state=snapshot(doc()),external=Object.freeze(new Map([['one',{text:'first'}]]));state.runtime.receipts=external;const one=encodeRecovery(state);external.get('one').text='second';external.set('two',{text:'other'});const two=encodeRecovery({...state,rootRevision:1});assert.deepEqual(two.payload.runtime.receipts,[['one',{text:'second'}],['two',{text:'other'}]]);assert.equal(one.payload.runtime.receipts.length,1);assert.equal(one.payload.runtime.receipts[0][1].text,'first');assert.equal(Object.isFrozen(external.get('one')),false);
// readMap 保护表成员关系,shallow 仅冻结第一层;嵌套 mutable 仍须在每次编码时重新捕获。
const mutable={text:'first'},shallow=Object.freeze({mutable}),owned=readMap(new Map([['one',shallow]]));state.runtime.receipts=owned;const three=encodeRecovery({...state,rootRevision:2});mutable.text='changed';const four=encodeRecovery({...state,rootRevision:3});assert.equal(three.payload.runtime.receipts[0][1].mutable.text,'first');assert.equal(four.payload.runtime.receipts[0][1].mutable.text,'changed');assert.equal(Object.isFrozen(mutable),false);
});