Files
gods-eye-view/src/data/retryableLoad.test.mjs
T

157 lines
5.6 KiB
JavaScript

// src/data/retryableLoad.test.mjs
// The bundled data packs memoize their load. This pins the half of that
// contract the two hand-rolled caches got wrong (roadmap L7): a failure must
// not be remembered, or one transient error downgrades the whole session.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
RETRY_COOLDOWN_MAX_MS,
RETRY_COOLDOWN_MS,
createRetryableLoader,
} from './retryableLoad.js';
/** Controllable clock so cooldown assertions cost no wall time. */
function fakeClock(start = 0) {
let t = start;
return { now: () => t, advance(ms) { t += ms; } };
}
test('a successful load runs once and is shared by later callers', async () => {
let calls = 0;
const load = createRetryableLoader(async () => { calls += 1; return { pack: calls }; });
const first = await load();
const second = await load();
assert.equal(calls, 1);
assert.equal(first, second, 'the memoized value must be the same object');
});
test('concurrent callers share one in-flight load', async () => {
let calls = 0;
const load = createRetryableLoader(async () => {
calls += 1;
await new Promise((resolve) => setTimeout(resolve, 1));
return calls;
});
const [a, b, c] = await Promise.all([load(), load(), load()]);
assert.equal(calls, 1);
assert.deepEqual([a, b, c], [1, 1, 1]);
});
test('a failed load is not cached, but retries wait out a cooldown', async () => {
const clock = fakeClock();
let calls = 0;
const load = createRetryableLoader(async () => {
calls += 1;
if (calls === 1) throw new Error('pack unavailable');
return 'loaded';
}, { now: clock.now });
await assert.rejects(load(), /pack unavailable/);
assert.equal(calls, 1);
// Inside the cooldown a looping caller replays the recorded reason and
// never touches the loader — one broken pack must not become a hot loop.
clock.advance(RETRY_COOLDOWN_MS - 1);
await assert.rejects(load(), /pack unavailable/);
await assert.rejects(load(), /pack unavailable/);
assert.equal(calls, 1, 'the cooldown must suppress the retry, not just slow it');
clock.advance(1);
assert.equal(await load(), 'loaded', 'a transient failure must not poison the session');
assert.equal(calls, 2);
});
test('a success after the cooldown is cached permanently', async () => {
const clock = fakeClock();
let calls = 0;
const load = createRetryableLoader(async () => {
calls += 1;
if (calls === 1) throw new Error('pack unavailable');
return { pack: calls };
}, { now: clock.now });
await assert.rejects(load(), /pack unavailable/);
clock.advance(RETRY_COOLDOWN_MS);
const first = await load();
clock.advance(RETRY_COOLDOWN_MAX_MS * 10);
const second = await load();
assert.equal(calls, 2, 'a recovered loader must never run again');
assert.equal(first, second);
});
test('consecutive failures back off, capped at the ceiling', async () => {
const clock = fakeClock();
let calls = 0;
const load = createRetryableLoader(async () => {
calls += 1;
throw new Error('pack unavailable');
}, { now: clock.now, cooldownMs: 100, maxCooldownMs: 250 });
await assert.rejects(load(), /pack unavailable/); // calls 1 → wait 100
clock.advance(100);
await assert.rejects(load(), /pack unavailable/); // calls 2 → wait 200
clock.advance(100);
await assert.rejects(load(), /pack unavailable/);
assert.equal(calls, 2, 'the second cooldown is longer than the first');
clock.advance(100);
await assert.rejects(load(), /pack unavailable/); // calls 3 → wait 250 (capped)
assert.equal(calls, 3);
clock.advance(250);
await assert.rejects(load(), /pack unavailable/);
assert.equal(calls, 4, 'the backoff stops growing at the ceiling');
});
test('a falsy rejection still counts as a failure and still gets a cooldown', async () => {
// The cooldown gate keys off an explicit flag, not the rejection value —
// a loader rejecting with null/0/'' must not slip past it.
for (const reason of [null, undefined, 0, '']) {
const clock = fakeClock();
let calls = 0;
const load = createRetryableLoader(async () => {
calls += 1;
if (calls === 1) throw reason;
return 'loaded';
}, { now: clock.now });
await assert.rejects(() => load(), (thrown) => thrown === reason || thrown === undefined);
clock.advance(RETRY_COOLDOWN_MS - 1);
await load().then(
() => assert.fail(`a ${String(reason)} rejection bypassed the cooldown`),
() => {},
);
assert.equal(calls, 1, `a ${String(reason)} rejection must not retry inside the cooldown`);
clock.advance(1);
assert.equal(await load(), 'loaded');
assert.equal(calls, 2);
}
});
test('a synchronous throw rejects and stays retryable', async () => {
const clock = fakeClock();
let calls = 0;
const load = createRetryableLoader(() => {
calls += 1;
if (calls === 1) throw new Error('bad path');
return 'loaded';
}, { now: clock.now });
await assert.rejects(load(), /bad path/);
clock.advance(RETRY_COOLDOWN_MS);
assert.equal(await load(), 'loaded');
});
test('every concurrent caller of a failing load sees the rejection, then retry succeeds', async () => {
const clock = fakeClock();
let calls = 0;
const load = createRetryableLoader(async () => {
calls += 1;
await new Promise((resolve) => setTimeout(resolve, 1));
if (calls === 1) throw new Error('pack unavailable');
return 'loaded';
}, { now: clock.now });
const results = await Promise.allSettled([load(), load()]);
assert.deepEqual(results.map((r) => r.status), ['rejected', 'rejected']);
assert.equal(calls, 1, 'the failing load is still shared, not duplicated');
clock.advance(RETRY_COOLDOWN_MS);
assert.equal(await load(), 'loaded');
});