306 lines
26 KiB
JavaScript
306 lines
26 KiB
JavaScript
/**
|
|
* 调度器状态机测试:从可变操作生成冻结请求,经持久化屏障后派发,未知结果走查询及原请求重试。
|
|
* 内存账本、可控 Promise 和假时钟隔离网络、磁盘与真实时间,验证 writer 占用、依赖及停止边界。
|
|
*/
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createLedger } from '../../src/lib/canvas-runtime/ledger.ts';
|
|
import { createScheduler } from '../../src/lib/canvas-runtime/scheduler.ts';
|
|
|
|
// 手动控制 Promise 完成时刻,让测试停在落盘、上传或服务端回执尚未完成的位置。
|
|
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
|
// 推进有限轮微任务供调度泵运行;不推进墙上时间,也不替代 scheduler.settle 的收敛等待。
|
|
const tick = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); };
|
|
// 建立可替换端口的调度环境;mutate 使用账本副本发布状态,persist 仅记录快照,默认服务立即成功。
|
|
function setup(overrides = {}, initial) {
|
|
let ledger = createLedger(initial), time = 1000, next = 0, timerId = 0;
|
|
const timers = new Map(), calls = [], persisted = [], errors = [];
|
|
const scheduler = createScheduler({
|
|
getLedger: () => ledger.getSnapshot(),
|
|
// 模拟提交边界:先在私有账本运行 reducer,成功后才替换供 scheduler 读取的当前账本。
|
|
mutate(reduce) { const draft = createLedger(ledger.getSnapshot()); reduce(draft); ledger = draft; },
|
|
async persist() { persisted.push(ledger.getSnapshot()); },
|
|
createId: () => `request-${++next}`,
|
|
participants: intent => intent.changes.map(change => change.id),
|
|
compile: (intent, requestId) => ({ canvasIds: intent.changes.map(change => change.id), expectedVersions: Object.fromEntries(intent.changes.map(change => [change.id, 1])), payload: { requestId, operationId: intent.id } }),
|
|
async submit(request) { calls.push(['submit', request]); return { outcome: 'applied' }; },
|
|
async query(request) { calls.push(['query', request]); return { outcome: 'applied' }; },
|
|
complete(request, status) { const draft = createLedger(ledger.getSnapshot()); draft.result(request.id, status.outcome, status.error); ledger = draft; },
|
|
// 假时钟仅登记定时器,advance 才触发到期回调;retryDelay 固定为 100 便于检查查询与重试次序。
|
|
clock: { now: () => time, setTimer(callback, delay) { const id = ++timerId; timers.set(id, { callback, at: time + delay }); return id; }, clearTimer: id => timers.delete(id) },
|
|
retryDelay: () => 100,
|
|
onError: error => errors.push(error), ...overrides,
|
|
});
|
|
return { scheduler, calls, persisted, errors, ledger: () => ledger,
|
|
// 添加文档操作并通知调度器;测试用 changes 的 ID 同时表示参与 canvas,省去真实文档编译。
|
|
add(id, canvases = ['canvas'], dependencies = []) { ledger.add({ id, operationType: 'document.change', operationSchemaVersion: 1, sequence: ledger.getSnapshot().operations.size + 1, origin: 'user', changes: canvases.map(id => ({ kind: 'canvas', id, before: null, after: { id, parent: null } })), dependencies }, {}); scheduler.notify([id]); },
|
|
// 先移动时间并执行当前已到期回调,再推进微任务;有意保持网络挂起时可跳过 settle。
|
|
async advance(ms, settle = true) { time += ms; for (const [id, timer] of [...timers]) if (timer.at <= time) { timers.delete(id); timer.callback(); } await tick(); if (settle) await scheduler.settle(); },
|
|
timerCount: () => timers.size,
|
|
};
|
|
}
|
|
|
|
// 用持久化屏障挡住网络;冻结请求已占 writer,但屏障解除后才允许发送,并在成功后唤醒依赖删除。
|
|
test('freeze and dispatch evidence must be durable before submit, then dependencies wake', async () => {
|
|
const barrier = deferred(), sent = [], state = setup({ persist: () => barrier.promise, submit: async request => { sent.push(request); return { outcome: 'applied' }; } });
|
|
state.add('create'); state.add('delete', ['canvas'], ['create']); state.scheduler.start(); await tick();
|
|
assert.equal(sent.length, 0);
|
|
const frozen = [...state.ledger().getSnapshot().requests.values()][0];
|
|
assert.equal(frozen.phase, 'dispatch-marked'); assert.equal(state.scheduler.getClaims().get('canvas'), frozen.id);
|
|
barrier.resolve(); await state.scheduler.settle();
|
|
assert.equal(sent.length, 2); assert.equal(state.ledger().getSnapshot().operations.get('delete').phase, 'resolved');
|
|
assert.equal(state.scheduler.getClaims().size, 0); state.scheduler.stop();
|
|
});
|
|
|
|
// 一个 canvas 的未知结果保持占用;其他 canvas 可继续,accepted 仍需查询,直到 applied 才放行同 canvas 后续操作。
|
|
test('unknown request holds its canvas; another canvas proceeds; accepted stays on query path', async () => {
|
|
const calls = []; let queries = 0;
|
|
const state = setup({
|
|
async submit(request) { calls.push(['submit', request]); return request.operationIds[0] === 'create' ? { outcome: 'unresolved' } : { outcome: 'applied' }; },
|
|
async query(request) { calls.push(['query', request]); return { outcome: ++queries === 1 ? 'accepted' : 'applied' }; },
|
|
});
|
|
state.add('create'); state.add('same-canvas'); state.add('independent', ['other']); state.scheduler.start(); await state.scheduler.settle();
|
|
assert.deepEqual(calls.map(([_, request]) => request.operationIds[0]), ['create', 'independent']);
|
|
assert.equal(state.scheduler.getClaims().size, 1); assert.equal(state.timerCount(), 1);
|
|
await state.advance(100); assert.equal(calls.at(-1)[0], 'query');
|
|
assert.equal(state.ledger().getSnapshot().operations.get('create').phase, 'frozen');
|
|
await state.advance(100);
|
|
assert.deepEqual(calls.map(([action, request]) => [action, request.operationIds[0]]), [['submit','create'], ['submit','independent'], ['query','create'], ['query','create'], ['submit','same-canvas']]);
|
|
assert.equal(state.scheduler.getClaims().size, 0); state.scheduler.stop();
|
|
});
|
|
|
|
// 恢复后先查回执;回执未知才重发,查询和重发都必须沿用保存的 ID 与字节,不能新建第二个请求。
|
|
test('receipt missing after recovery triggers exact retry, never a new identity/body', async () => {
|
|
const original = setup({ submit: async () => ({ outcome: 'unresolved' }) });
|
|
original.add('create'); original.scheduler.start(); await original.scheduler.settle();
|
|
const saved = original.ledger().getSnapshot(), before = saved.requests.get('request-1'); original.scheduler.stop();
|
|
const calls = [];
|
|
const restored = setup({ query: async request => { calls.push(['query', request]); return { outcome: 'unresolved', error: 'receipt_unknown' }; }, submit: async request => { calls.push(['submit', request]); return { outcome: 'applied' }; } }, saved);
|
|
restored.scheduler.start(); await restored.advance(100); await restored.advance(100);
|
|
assert.deepEqual(calls.map(([action]) => action), ['query', 'submit']);
|
|
for (const [, request] of calls) { assert.equal(request.id, before.id); assert.equal(request.body, before.body); }
|
|
assert.equal(restored.ledger().getSnapshot().requests.size, 1); restored.scheduler.stop();
|
|
});
|
|
|
|
// 落盘失败时不发送且不释放占用;显式恢复先重新持久化,再按已有派发证据保守查询。
|
|
test('storage failure never sends and keeps claims; explicit resume retries persistence first', async () => {
|
|
let broken = true; const sent = [];
|
|
const state = setup({ persist: async () => { if (broken) throw new Error('quota'); }, submit: async request => { sent.push(request); return { outcome: 'applied' }; } });
|
|
state.add('create'); state.scheduler.start(); await state.scheduler.settle();
|
|
assert.equal(sent.length, 0); assert.equal(state.errors[0].message, 'quota'); assert.equal(state.scheduler.getClaims().size, 1);
|
|
broken = false; await state.scheduler.resume(); await state.scheduler.settle();
|
|
// Dispatch evidence means query first, even if the failed barrier preceded the network call.
|
|
assert.equal(state.calls[0][0], 'query'); assert.equal(sent.length, 0); assert.equal(state.scheduler.getClaims().size, 0); state.scheduler.stop();
|
|
});
|
|
|
|
// 跨两个 canvas 的操作同时持有两个 writer;停止后的迟到成功不能写回账本,也不能重新启动已关闭实例。
|
|
test('multi-canvas claims are atomic and stop fences late results', async () => {
|
|
const response = deferred(), state = setup({ submit: () => response.promise });
|
|
state.add('cross', ['a', 'b']); state.add('single', ['b']); state.scheduler.start(); await tick();
|
|
assert.equal(new Set(state.scheduler.getClaims().values()).size, 1); assert.equal(state.scheduler.getClaims().size, 2);
|
|
state.scheduler.stop(); response.resolve({ outcome: 'applied' }); await state.scheduler.settle();
|
|
assert.equal(state.ledger().getSnapshot().requests.get('request-1').outcome, 'unresolved');
|
|
assert.throws(() => state.scheduler.start(), /scheduler_closed/);
|
|
});
|
|
|
|
// 无法解释未来协议请求时保留原字节和 writer,并标记不可解释;无关 canvas 的新操作仍可完成。
|
|
test('missing frozen-request interpreter preserves bytes and claims while unrelated work proceeds', async () => {
|
|
const initial = setup({ submit: async () => ({ outcome: 'unresolved' }) }); initial.add('create'); initial.scheduler.start(); await initial.scheduler.settle(); initial.scheduler.stop();
|
|
const saved = initial.ledger().getSnapshot(), requests = new Map(saved.requests), old = requests.get('request-1');
|
|
requests.set(old.id, { ...old, protocolVersion: 99 });
|
|
const restored = setup({ createId: () => 'other-request' }, { ...saved, requests });
|
|
restored.add('other', ['other']); restored.scheduler.start(); await restored.scheduler.settle();
|
|
assert.equal(restored.calls.length, 1); assert.equal(restored.calls[0][1].id, 'other-request');
|
|
assert.equal(restored.ledger().getSnapshot().requests.get(old.id).body, old.body);
|
|
assert.equal(restored.ledger().getSnapshot().requests.get(old.id).error, 'request_interpreter_unavailable');
|
|
assert.equal(restored.scheduler.getClaims().get('canvas'), old.id); restored.scheduler.stop();
|
|
});
|
|
|
|
// 身份变化等阻塞结果没有证明零写入,故保留未知占用;同时停止自动重试,避免无权限的循环请求。
|
|
test('permission/protocol rejection keeps unknown claims without an automatic retry loop', async()=>{
|
|
const calls=[],state=setup({submit:async request=>{calls.push(request);return{outcome:'unresolved',blocked:true,error:'identity_changed'}}});
|
|
state.add('create');state.scheduler.start();await state.scheduler.settle();
|
|
assert.equal(state.ledger().getSnapshot().requests.get('request-1').outcome,'unresolved');assert.equal(state.scheduler.getClaims().size,1);assert.equal(state.timerCount(),0);
|
|
await state.advance(10000);assert.equal(calls.length,1);state.scheduler.stop();
|
|
});
|
|
|
|
// 混合测试编译器:上传不占 canvas writer,文档操作按变化 ID 占用,二者仍共享网络调度约束。
|
|
const mixedCompile = (intent, requestId) => ({ kind: intent.operationType, protocolVersion: 2, canvasIds: intent.operationType === 'asset.upload' ? [] : intent.changes.map(change => change.id), expectedVersions: Object.fromEntries(intent.changes.map(change => [change.id, 1])), payload: { requestId, operationId: intent.id } });
|
|
// 插入独立资源上传并通知调度器,用于验证上传容量与文档任务的公平性。
|
|
function addAsset(state, id) {
|
|
state.ledger().add({ id, operationType: 'asset.upload', operationSchemaVersion: 1, sequence: state.ledger().getSnapshot().operations.size + 1, origin: 'user', changes: [], canvasIds: [], dependencies: [], resourceId: `resource-${id}` }, {});
|
|
state.scheduler.notify([id]);
|
|
}
|
|
|
|
// 80 个上传排队且首个挂起时,独立文档仍能进入网络;解除上传屏障后全部完成,无额外错误。
|
|
test('an upload burst yields without filling both network slots or starving independent edits', async () => {
|
|
const gate = deferred(), calls = [];
|
|
const state = setup({ compile: mixedCompile, submit: async request => { calls.push(request); if (request.operationIds[0] === 'asset-0') await gate.promise; return { outcome: 'applied' }; } });
|
|
for (let index = 0; index < 80; index++) addAsset(state, `asset-${index}`);
|
|
state.add('independent', ['canvas']); state.scheduler.start(); await tick();
|
|
await state.advance(0, false);
|
|
assert.deepEqual(calls.map(request => request.operationIds[0]), ['asset-0', 'independent']);
|
|
assert.equal(state.timerCount(), 0);
|
|
gate.resolve(); await state.scheduler.settle();
|
|
assert.equal(calls.length, 81); assert.equal(state.errors.length, 0); state.scheduler.stop();
|
|
});
|
|
|
|
// 上传容量已满时不反复唤醒同一重试;更晚到期的文档查询仍能执行,容量释放后再恢复上传查询。
|
|
test('a capacity-blocked upload retry does not spin or hide a later document retry deadline', async () => {
|
|
const gate = deferred(), calls = [];
|
|
const state = setup({ compile: mixedCompile,
|
|
submit: async request => {
|
|
const id = request.operationIds[0]; calls.push(['submit', id]);
|
|
if (id === 'asset-2') { await gate.promise; return { outcome: 'applied' }; }
|
|
return { outcome: 'unresolved', retryAfterMs: id === 'doc' ? 200 : 100 };
|
|
},
|
|
query: async request => { calls.push(['query', request.operationIds[0]]); return { outcome: 'applied' }; },
|
|
});
|
|
addAsset(state, 'asset-1'); addAsset(state, 'asset-2'); state.add('doc'); state.scheduler.start(); await tick();
|
|
await state.advance(100, false); assert.equal(state.timerCount(), 1);
|
|
await state.advance(100, false);
|
|
assert.ok(calls.some(([action, id]) => action === 'query' && id === 'doc'));
|
|
assert.equal(calls.some(([action, id]) => action === 'query' && id === 'asset-1'), false);
|
|
assert.equal(state.timerCount(), 0);
|
|
gate.resolve(); await state.scheduler.settle();
|
|
assert.ok(calls.some(([action, id]) => action === 'query' && id === 'asset-1')); state.scheduler.stop();
|
|
});
|
|
|
|
// 嵌套 hold 不取消已进入网络的请求,但阻止新派发和到期重试;重复释放无效,最后一个 hold 释放才恢复。
|
|
test('nested holds finish admitted receipts but suppress dependent dispatch and due retries until the final release', async () => {
|
|
const response = deferred(), calls = [];
|
|
const state = setup({ concurrency: 1, submit: async request => { calls.push(['submit', request]); return request.operationIds[0] === 'first' ? response.promise : { outcome: 'applied' }; }, query: async request => { calls.push(['query', request]); return { outcome: 'applied' }; } });
|
|
state.add('first'); state.add('dependent', ['canvas'], ['first']); state.scheduler.start(); await tick();
|
|
const releaseA = state.scheduler.hold(), releaseB = state.scheduler.hold();
|
|
state.add('later', ['other']); response.resolve({ outcome: 'unresolved' }); await state.scheduler.settle();
|
|
const frozen = state.ledger().getSnapshot().requests.get('request-1');
|
|
assert.equal(frozen.outcome, 'unresolved'); assert.equal(state.scheduler.getClaims().get('canvas'), frozen.id);
|
|
await state.advance(1000); assert.equal(calls.length, 1); assert.equal(state.timerCount(), 0);
|
|
releaseA(); releaseA(); await tick(); assert.equal(calls.length, 1);
|
|
releaseB(); await state.scheduler.settle();
|
|
assert.deepEqual(calls.map(([action, request]) => [action, request.operationIds[0]]), [['submit', 'first'], ['query', 'first'], ['submit', 'later'], ['submit', 'dependent']]);
|
|
assert.equal(calls[1][1].id, frozen.id); assert.equal(calls[1][1].body, frozen.body); assert.equal(state.scheduler.getClaims().size, 0);
|
|
state.scheduler.stop();
|
|
});
|
|
|
|
// 启动前 hold 保留已通知工作;stop 是终态,之后释放 hold 也不能复活调度器。
|
|
test('holds acquired before start retain notified work; stop is terminal even when a hold is later released', async () => {
|
|
const state = setup(), release = state.scheduler.hold(); state.add('first'); state.scheduler.start(); await tick();
|
|
assert.equal(state.calls.length, 0); release(); await state.scheduler.settle(); assert.equal(state.calls.length, 1);
|
|
const finalRelease = state.scheduler.hold(); state.add('never'); state.scheduler.stop(); finalRelease(); await tick();
|
|
assert.equal(state.calls.length, 1); assert.throws(() => state.scheduler.hold(), /scheduler_closed/);
|
|
});
|
|
|
|
// 批量编译仅打包逻辑操作并汇总排序后的 canvas;这里不承担真实 wire 字段压缩。
|
|
const compileBatch = (intents, requestId) => {
|
|
const canvasIds = [...new Set(intents.flatMap(intent => intent.changes.map(change => change.id)))].sort();
|
|
return { canvasIds, expectedVersions: Object.fromEntries(canvasIds.map(id => [id, 1])), payload: { requestId, operations: intents.map(intent => ({ id: intent.id, changes: intent.changes })) } };
|
|
};
|
|
|
|
// 同一微任务窗口内的依赖链可打成一批;整批共享持久化屏障,成功后所有逻辑操作一并解决。
|
|
test('mutable microtask window batches dependent document IDs behind one durable dispatch barrier', async () => {
|
|
const barrier = deferred(), sent = [];
|
|
const state = setup({ compileBatch, persist: () => barrier.promise, submit: async request => { sent.push(request); return { outcome: 'applied' }; } });
|
|
state.scheduler.start(); state.add('create'); state.add('rename', ['canvas'], ['create']); state.add('move', ['canvas'], ['rename']);
|
|
assert.equal(state.ledger().getSnapshot().requests.size, 0);
|
|
await tick();
|
|
const frozen = [...state.ledger().getSnapshot().requests.values()][0];
|
|
assert.deepEqual(frozen.operationIds, ['create', 'rename', 'move']); assert.equal(frozen.phase, 'dispatch-marked'); assert.equal(sent.length, 0);
|
|
assert.deepEqual(JSON.parse(frozen.body).operations.map(op => op.id), frozen.operationIds);
|
|
barrier.resolve(); await state.scheduler.settle();
|
|
assert.equal(sent.length, 1); assert.equal(state.scheduler.getClaims().size, 0);
|
|
for (const id of frozen.operationIds) assert.equal(state.ledger().getSnapshot().operations.get(id).phase, 'resolved');
|
|
state.scheduler.stop();
|
|
});
|
|
|
|
// 先验证不同 canvas 通道不会任意并批,再加入观察前序操作的 run 记录,验证它形成批次边界。
|
|
test('batching retains independent lanes and does not cross an external dependency or task observer', async () => {
|
|
const pending = deferred(), sent = [], state = setup({ compileBatch, submit: async request => { sent.push(request); return pending.promise; } });
|
|
state.add('a1', ['a']); state.add('b1', ['b']); state.add('a2', ['a'], ['a1']);
|
|
state.scheduler.start(); await tick();
|
|
assert.deepEqual(sent.map(request => request.operationIds), [['a1'], ['b1']]);
|
|
pending.resolve({ outcome: 'applied' }); await state.scheduler.settle();
|
|
assert.deepEqual(sent.map(request => request.operationIds), [['a1'], ['b1'], ['a2']]); state.scheduler.stop();
|
|
|
|
const observed = setup({ compileBatch });
|
|
observed.add('before');
|
|
observed.ledger().add({ id: 'run', operationType: 'run.start', operationSchemaVersion: 1, sequence: 2, origin: 'user', changes: [], dependencies: ['before'] }, {});
|
|
observed.ledger().block('run', ['awaiting-input']); observed.scheduler.notify(['run']);
|
|
observed.add('after', ['canvas'], ['before']); observed.scheduler.start(); await observed.scheduler.settle();
|
|
assert.deepEqual(observed.calls.map(([, request]) => request.operationIds), [['before'], ['after']]); observed.scheduler.stop();
|
|
});
|
|
|
|
// 未知批次冻结全部逻辑 ID 和原始请求体;后来编辑保持 pending,查询及重发不能吸收它。
|
|
test('unknown batch preserves all IDs, claims, and exact bytes across query and retry; later edits stay mutable', async () => {
|
|
const sent = [], state = setup({ compileBatch, submit: async request => { sent.push(request); return { outcome: 'unresolved' }; }, query: async () => ({ outcome: 'unresolved' }) });
|
|
state.add('one'); state.add('two', ['canvas'], ['one']); state.scheduler.start(); await state.scheduler.settle();
|
|
const original = sent[0]; state.add('three', ['canvas'], ['two']); await state.scheduler.settle();
|
|
assert.equal(sent.length, 1); assert.equal(state.ledger().getSnapshot().operations.get('three').phase, 'pending');
|
|
await state.advance(100); await state.advance(100);
|
|
assert.equal(sent.length, 2); assert.equal(sent[1].id, original.id); assert.equal(sent[1].body, original.body); assert.deepEqual(sent[1].operationIds, ['one', 'two']);
|
|
assert.equal(state.scheduler.getClaims().get('canvas'), original.id); state.scheduler.stop();
|
|
});
|
|
|
|
// hold 阻止批次入场;释放后 130 个依赖操作按 64、64、2 排空,不依赖推进假定时器。
|
|
test('bounded batches drain through settle without waiting for fake timers and holds suppress scheduled admission', async () => {
|
|
const state = setup({ compileBatch }); state.scheduler.start(); const release = state.scheduler.hold();
|
|
for (let i = 0; i < 130; i++) state.add(`op-${i}`, ['canvas'], i ? [`op-${i - 1}`] : []);
|
|
await state.scheduler.settle(); assert.equal(state.calls.length, 0);
|
|
release(); await state.scheduler.settle();
|
|
assert.deepEqual(state.calls.map(([, request]) => request.operationIds.length), [64, 64, 2]);
|
|
assert.equal(state.ledger().getSnapshot().operations.get('op-129').phase, 'resolved'); state.scheduler.stop();
|
|
});
|
|
|
|
// 状态发布因存储 leader 丢失而失败时,错误通过 onError 报告;操作保持 pending,不能生成或发送请求。
|
|
test('a fenced pump reports failed state publication without throwing or sending through the old identity',async()=>{
|
|
const state=setup({mutate(){throw Error('storage_leader_lost');}});state.scheduler.start();state.add('fenced');await tick();await state.scheduler.settle();assert.equal(state.calls.length,0);assert.equal(state.ledger().getSnapshot().requests.size,0);assert.equal(state.ledger().getSnapshot().operations.get('fenced').phase,'pending');assert.ok(state.errors.some(error=>error.message==='storage_leader_lost'));state.scheduler.stop();
|
|
});
|
|
|
|
// Wall time can change independently of the elapsed time used by browser timers.
|
|
// 分离墙上时间与定时器经过时间:可单独跳动 wall,或推进 elapsed 并返回旧回调来模拟迟到触发。
|
|
function adjustableClock(initial=1000){
|
|
let wall=initial,elapsed=0,next=0;const timers=new Map();
|
|
return{clock:{now:()=>wall,setTimer(callback,delay){const id=++next;timers.set(id,{callback,delay,at:elapsed+delay});return id;},clearTimer:id=>timers.delete(id)},timers,
|
|
setWall(value){wall=value;},fireAfter(ms){elapsed+=ms;const due=[...timers].filter(([,timer])=>timer.at<=elapsed);for(const[id,timer]of due){timers.delete(id);timer.callback();}return due.map(([,timer])=>timer.callback);}};
|
|
}
|
|
|
|
// 墙上时钟回退时重新计算剩余等待,前跳时只查同一冻结请求;旧回调重复触发不能重复查询或越过依赖。
|
|
test('wall-clock jumps recheck retry deadlines without releasing unknown claims or bypassing dependencies',async()=>{
|
|
const clock=adjustableClock(),receipt=deferred(),calls=[];
|
|
const state=setup({clock:clock.clock,
|
|
async submit(request){calls.push(['submit',request]);return{outcome:request.operationIds[0]==='first'?'unresolved':'applied'};},
|
|
async query(request){calls.push(['query',request]);return receipt.promise;},
|
|
});
|
|
state.add('first');state.add('dependent',['canvas'],['first']);state.scheduler.start();await state.scheduler.settle();
|
|
const frozen=state.ledger().getSnapshot().requests.get('request-1');assert.equal(frozen.retryAt,1100);
|
|
clock.setWall(800);clock.fireAfter(100);await state.scheduler.settle();
|
|
assert.equal(calls.length,1);assert.equal(state.scheduler.getClaims().get('canvas'),frozen.id);
|
|
assert.equal(clock.timers.size,1);assert.equal([...clock.timers.values()][0].delay,300);
|
|
state.add('independent',['other']);await state.scheduler.settle();
|
|
assert.deepEqual(calls.map(([action,request])=>[action,request.operationIds[0]]),[['submit','first'],['submit','independent']]);
|
|
clock.setWall(5000);const staleCallbacks=clock.fireAfter(300);await tick();
|
|
assert.equal(calls.at(-1)[0],'query');assert.equal(calls.at(-1)[1].body,frozen.body);assert.equal(calls.at(-1)[1].id,frozen.id);
|
|
assert.equal(state.scheduler.getClaims().get('canvas'),frozen.id);assert.equal(state.ledger().getSnapshot().operations.get('dependent').phase,'pending');
|
|
for(const callback of staleCallbacks)callback();await tick();assert.equal(calls.length,3);
|
|
receipt.resolve({outcome:'applied'});await state.scheduler.settle();
|
|
assert.deepEqual(calls.map(([action,request])=>[action,request.operationIds[0]]),[['submit','first'],['submit','independent'],['query','first'],['submit','dependent']]);
|
|
for(const callback of staleCallbacks)callback();await state.scheduler.settle();assert.equal(calls.length,4);assert.equal(clock.timers.size,0);assert.equal(state.scheduler.getClaims().size,0);state.scheduler.stop();
|
|
});
|
|
|
|
// 分别从回退及前跳的墙上时间恢复,验证最早有效截止时间先查询;恢复过程仍保持原请求体和逻辑 ID。
|
|
test('recovered retries recompute remaining wall time and wake the earliest valid request after either clock shift',async()=>{
|
|
const original=setup({async submit(request){return{outcome:'unresolved',retryAfterMs:request.operationIds[0]==='long'?500:100};}});
|
|
original.add('long',['a']);original.add('short',['b']);original.scheduler.start();await original.scheduler.settle();const saved=original.ledger().getSnapshot();original.scheduler.stop();
|
|
for(const now of[800,1200]){
|
|
const clock=adjustableClock(now),calls=[],restored=setup({clock:clock.clock,async query(request){calls.push(request);return{outcome:'applied'};}},saved);
|
|
restored.scheduler.start();await restored.scheduler.settle();
|
|
assert.deepEqual(calls.map(request=>request.operationIds[0]),now===800?[]:['short']);
|
|
assert.equal(clock.timers.size,1);assert.equal([...clock.timers.values()][0].delay,300);
|
|
if(now===800){clock.setWall(1100);clock.fireAfter(300);await restored.scheduler.settle();assert.deepEqual(calls.map(request=>request.operationIds[0]),['short']);assert.equal([...clock.timers.values()][0].delay,400);}
|
|
clock.setWall(1500);clock.fireAfter(now===800?400:300);await restored.scheduler.settle();
|
|
assert.deepEqual(calls.map(request=>request.operationIds[0]),['short','long']);
|
|
for(const request of calls){const before=saved.requests.get(request.id);assert.equal(request.body,before.body);assert.deepEqual(request.operationIds,before.operationIds);}
|
|
assert.equal(clock.timers.size,0);assert.equal(restored.scheduler.getClaims().size,0);restored.scheduler.stop();
|
|
}
|
|
});
|