260 lines
13 KiB
TypeScript
260 lines
13 KiB
TypeScript
// The read registry (src/lib/read-registry.ts) over the scheduler, with a fake clock: single-flight per key, scope
|
|
// overlap (tags, broad reads covering narrow keys), "read just now", invalidation, one error to every waiter,
|
|
// cancellation, the quota pause on the query, which reads a write invalidates; and the component level: two read
|
|
// buttons whose reads overlap both render busy and disabled from the registry (react-dom/server, no DOM needed).
|
|
import { describe, expect, test } from 'bun:test';
|
|
import { createElement } from 'react';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { readKey } from '../src/lib/freshness';
|
|
import { affectedReads, appTags, PORTFOLIO_KEY, READ_ALL_KEY, type ReadQuery, ReadRegistry, type ReadSpec } from '../src/lib/read-registry';
|
|
import { RegistryCtx, useReadState } from '../src/store';
|
|
import { type Clock, ReadScheduler } from '../src/lib/scheduler';
|
|
|
|
class FakeClock implements Clock {
|
|
t = 1_000_000;
|
|
private timers: { at: number; fn: () => void; id: number }[] = [];
|
|
private n = 0;
|
|
now() { return this.t; }
|
|
setTimeout(fn: () => void, ms: number) { const id = ++this.n; this.timers.push({ at: this.t + ms, fn, id }); return id; }
|
|
clearTimeout(h: unknown) { this.timers = this.timers.filter(x => x.id !== h); }
|
|
random() { return 0.5; }
|
|
async advance(ms: number) {
|
|
const end = this.t + ms;
|
|
for (;;) {
|
|
await flush();
|
|
const next = this.timers.filter(x => x.at <= end).sort((a, b) => a.at - b.at)[0];
|
|
if (!next) break;
|
|
this.t = next.at;
|
|
this.timers = this.timers.filter(x => x !== next);
|
|
next.fn();
|
|
}
|
|
this.t = end;
|
|
await flush();
|
|
}
|
|
}
|
|
const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); };
|
|
|
|
/** A registry whose reads resolve when the test says so; `calls` counts host calls per key. */
|
|
function setup(o: { justNowMs?: number } = {}) {
|
|
const clock = new FakeClock();
|
|
const sched = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 4, play: 4, local: 2 } });
|
|
const events: string[] = [];
|
|
const reg = new ReadRegistry(sched, { clock, justNowMs: o.justNowMs ?? 20_000, onEvent: (k, e) => events.push(`${e.kind}:${k}`) });
|
|
const calls = new Map<string, number>();
|
|
const pending = new Map<string, { done: (v: unknown) => void; fail: (e: unknown) => void }[]>();
|
|
const spec = (key: string, x: Partial<ReadSpec<unknown>> = {}): ReadSpec<unknown> => ({
|
|
key, lanes: ['apple'], accounts: ['apple:k1'], priority: 0, tags: x.tags ?? appTags('app1'), ...x,
|
|
run: () => new Promise((done, fail) => {
|
|
calls.set(key, (calls.get(key) ?? 0) + 1);
|
|
pending.set(key, [...(pending.get(key) ?? []), { done, fail }]);
|
|
}),
|
|
});
|
|
const finish = (key: string, v: unknown = { ok: true }) => { for (const p of pending.get(key) ?? []) p.done(v); pending.delete(key); };
|
|
const fail = (key: string, e: unknown) => { for (const p of pending.get(key) ?? []) p.fail(e); pending.delete(key); };
|
|
return { clock, sched, reg, calls, spec, finish, fail, events };
|
|
}
|
|
|
|
const K1 = readKey('app1', 'health', {});
|
|
const K2 = readKey('app1', 'reviews.list', { days: 30 });
|
|
const K3 = readKey('app2', 'health', {});
|
|
|
|
describe('ReadRegistry', () => {
|
|
test('single-flight: the same key twice is one host call and one promise; both get the result', async () => {
|
|
const { clock, reg, calls, spec, finish, events } = setup();
|
|
const a = reg.read(spec(K1));
|
|
const b = reg.read(spec(K1));
|
|
await clock.advance(0);
|
|
expect(calls.get(K1)).toBe(1);
|
|
expect(reg.state(K1).busy).toBe(true);
|
|
finish(K1, { ok: true, n: 7 });
|
|
expect(await a).toEqual({ ok: true, n: 7 });
|
|
expect(await b).toEqual({ ok: true, n: 7 });
|
|
expect(reg.state(K1).busy).toBe(false);
|
|
// the bus hears one start and one done, never a join
|
|
expect(events).toEqual([`start:${K1}`, `done:${K1}`]);
|
|
});
|
|
|
|
test('just now: a second click right after a finished read answers from it; after the window it reads again', async () => {
|
|
const { clock, reg, calls, spec, finish } = setup({ justNowMs: 10_000 });
|
|
const a = reg.read(spec(K1));
|
|
await clock.advance(0); finish(K1, 'first'); await a;
|
|
expect(reg.state(K1).justNow).toBe(true);
|
|
expect(await reg.read(spec(K1))).toBe('first');
|
|
expect(calls.get(K1)).toBe(1);
|
|
await clock.advance(10_001);
|
|
expect(reg.state(K1).justNow).toBe(false);
|
|
const c = reg.read(spec(K1));
|
|
await clock.advance(0); finish(K1, 'second');
|
|
expect(await c).toBe('second');
|
|
expect(calls.get(K1)).toBe(2);
|
|
// force skips the memo
|
|
const f = reg.read(spec(K1), { force: true });
|
|
await clock.advance(0); finish(K1, 'third');
|
|
expect(await f).toBe('third');
|
|
expect(calls.get(K1)).toBe(3);
|
|
});
|
|
|
|
test('invalidation drops the memo of the keys a write touched, and only those', async () => {
|
|
const { clock, reg, calls, spec, finish } = setup();
|
|
for (const k of [K1, K2]) { const p = reg.read(spec(k)); await clock.advance(0); finish(k); await p; }
|
|
expect(reg.invalidate(k => k === K2)).toEqual([K2]);
|
|
reg.read(spec(K1)).catch(() => {}); reg.read(spec(K2)).catch(() => {});
|
|
await clock.advance(0);
|
|
expect(calls.get(K1)).toBe(1);
|
|
expect(calls.get(K2)).toBe(2);
|
|
});
|
|
|
|
test('an error reaches every waiter, is the key\'s error, and is not memoized', async () => {
|
|
const { clock, reg, calls, spec, fail, finish } = setup();
|
|
const a = reg.read(spec(K1)), b = reg.read(spec(K1));
|
|
await clock.advance(0);
|
|
fail(K1, new Error('403 SERVICE_DISABLED'));
|
|
await expect(a).rejects.toThrow('SERVICE_DISABLED');
|
|
await expect(b).rejects.toThrow('SERVICE_DISABLED');
|
|
expect(reg.state(K1)).toMatchObject({ busy: false, error: '403 SERVICE_DISABLED', justNow: false });
|
|
const c = reg.read(spec(K1));
|
|
await clock.advance(0); finish(K1, 'ok');
|
|
expect(await c).toBe('ok');
|
|
expect(calls.get(K1)).toBe(2);
|
|
expect(reg.state(K1).error).toBeNull();
|
|
});
|
|
|
|
test('scope: a narrow read makes the broad tag busy; another app\'s query stays free', async () => {
|
|
const { clock, reg, spec, finish } = setup();
|
|
reg.read(spec(K1)).catch(() => {});
|
|
await clock.advance(0);
|
|
expect(reg.state({ tags: ['all'] }).busy).toBe(true);
|
|
expect(reg.state({ tags: ['app:app1'] }).busy).toBe(true);
|
|
expect(reg.state(K3).busy).toBe(false);
|
|
expect(reg.state({ keys: [K3, K1] }).busy).toBe(true);
|
|
finish(K1);
|
|
await flush();
|
|
expect(reg.state({ tags: ['all'] }).busy).toBe(false);
|
|
});
|
|
|
|
test('a broad read covers its narrow keys: they are busy at once, a click on one waits and does not read again', async () => {
|
|
const { clock, reg, calls, spec, finish } = setup();
|
|
let release!: (k: string) => void;
|
|
let go!: () => void;
|
|
const gate = new Promise<void>(r => { go = r; });
|
|
const all = reg.group({ key: READ_ALL_KEY, tags: ['all'], covers: [PORTFOLIO_KEY, K1, K2], run: async g => {
|
|
release = g.release;
|
|
await gate; // the TTL checks
|
|
const ps = [K1, K2].map(k => { g.release(k); return reg.read(spec(k)); });
|
|
return Promise.all(ps);
|
|
} });
|
|
await flush();
|
|
// nothing started yet, but every covered key already shows busy
|
|
expect(calls.size).toBe(0);
|
|
for (const k of [K1, K2, PORTFOLIO_KEY]) expect(reg.state(k).busy).toBe(true);
|
|
expect(reg.state({ tags: ['all'] }).busy).toBe(true);
|
|
// the card's refresh click while covered: joins the group, no read of its own
|
|
const click = reg.read(spec(K1));
|
|
// a second Refresh all joins the first
|
|
const again = reg.group({ key: READ_ALL_KEY, covers: [], run: async () => { throw new Error('must not run'); } });
|
|
expect(again).toBe(all as Promise<unknown> as typeof again);
|
|
release(PORTFOLIO_KEY);
|
|
expect(reg.state(PORTFOLIO_KEY).busy).toBe(false);
|
|
go();
|
|
await clock.advance(0);
|
|
expect(calls.get(K1)).toBe(1);
|
|
finish(K1, 'one'); finish(K2, 'two');
|
|
await all;
|
|
expect(await click).toBe('one');
|
|
expect(calls.get(K1)).toBe(1);
|
|
expect(reg.state({ tags: ['all'] }).busy).toBe(false);
|
|
});
|
|
|
|
test('cancel: a queued read is dropped and every waiter hears "cancelled"; a running one cancels its job', async () => {
|
|
const { clock, sched, reg, spec } = setup();
|
|
sched.setLimits({ apple: 1 });
|
|
let cancelled = 0;
|
|
reg.read(spec(K1, { cancel: () => { cancelled++; } })).catch(() => {});
|
|
const q1 = reg.read(spec(K3)), q2 = reg.read(spec(K3));
|
|
await clock.advance(0);
|
|
reg.cancel(K3);
|
|
await expect(q1).rejects.toThrow('cancelled');
|
|
await expect(q2).rejects.toThrow('cancelled');
|
|
await flush();
|
|
expect(reg.state(K3).busy).toBe(false);
|
|
// a cancel is not a failure the card keeps showing
|
|
expect(reg.state(K3).error).toBeNull();
|
|
reg.cancel(K1);
|
|
expect(cancelled).toBe(1);
|
|
});
|
|
|
|
test('a 429 backoff of the account shows as paused on every query that uses it', async () => {
|
|
const clock = new FakeClock();
|
|
let n = 0;
|
|
const sched = new ReadScheduler({ clock, spacingMs: 0, backoffBaseMs: 60_000, classify: () => ({ throttled: n++ === 0 }) });
|
|
const reg = new ReadRegistry(sched, { clock });
|
|
const p = reg.read({ key: K1, tags: appTags('app1'), lanes: ['play'], accounts: ['play:acc'], priority: 0, run: async () => 'x' });
|
|
await clock.advance(0);
|
|
const st = reg.state(K1);
|
|
expect(st.busy).toBe(true);
|
|
expect(st.pausedUntil).toBeGreaterThan(clock.now());
|
|
expect(reg.state({ tags: ['all'] }).pausedUntil).toBe(st.pausedUntil);
|
|
await clock.advance(120_000);
|
|
expect(await p).toBe('x');
|
|
expect(reg.state(K1).pausedUntil).toBeNull();
|
|
});
|
|
|
|
test('a joining click with a higher priority raises the queued read', async () => {
|
|
const { clock, sched, reg, calls, spec, finish } = setup();
|
|
sched.setLimits({ apple: 1 });
|
|
reg.read(spec('blocker')).catch(() => {});
|
|
reg.read(spec(K2, { priority: 2 })).catch(() => {});
|
|
reg.read(spec(K3, { priority: 2 })).catch(() => {});
|
|
reg.read(spec(K3, { priority: 0 })).catch(() => {});
|
|
await clock.advance(0);
|
|
expect([...calls.keys()]).toEqual(['blocker']);
|
|
expect(calls.get(K3)).toBeUndefined();
|
|
// still one queued read per key; the joined K3 now goes before K2
|
|
expect(sched.stats().queued).toBe(2);
|
|
finish('blocker');
|
|
await clock.advance(0);
|
|
expect(calls.get(K3)).toBe(1);
|
|
expect(calls.get(K2)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('affectedReads', () => {
|
|
test('writes invalidate their own reads only; unknown writes everything', () => {
|
|
expect(affectedReads('reviews.reply').methods).toEqual(['reviews.list', 'reviews.drafts']);
|
|
expect(affectedReads('owner.play_rollout')).toEqual({ methods: ['apple.status', 'play.status'], portfolio: true });
|
|
expect(affectedReads('apple.testflight_distribute').methods).toEqual(['apple.testflight_status']);
|
|
expect(affectedReads('prices.import').methods).toContain('prices.current');
|
|
expect(affectedReads('prices.import').methods).not.toContain('health');
|
|
expect(affectedReads('art.pull').methods).toContain('art.live');
|
|
expect(affectedReads('project.edit')).toEqual({ methods: null, portfolio: true });
|
|
});
|
|
});
|
|
|
|
describe('two overlapping read buttons render busy together', () => {
|
|
test('a card refresh in flight: Refresh all, the card, Check again are busy and disabled; another app is not', async () => {
|
|
const { clock, reg, spec } = setup();
|
|
// the window's rule (src/ui/ReadButtons.tsx, ReadControl): useReadState(query).busy -> spinner + disabled
|
|
const Btn = ({ q, id }: { q: ReadQuery; id: string }) => {
|
|
const s = useReadState(q);
|
|
return createElement('button', { 'data-id': id, disabled: s.busy, 'aria-busy': s.busy }, s.busy ? 'spin' : 'idle');
|
|
};
|
|
const view = () => renderToStaticMarkup(createElement(RegistryCtx.Provider, { value: reg },
|
|
createElement(Btn, { q: { tags: ['all'] }, id: 'refresh-all' }),
|
|
createElement(Btn, { q: K1, id: 'health-card' }),
|
|
createElement(Btn, { q: { keys: [K1, K3] }, id: 'check-again' }),
|
|
createElement(Btn, { q: readKey('app9', 'scan', {}), id: 'other-app' })));
|
|
expect(view()).not.toContain('disabled');
|
|
reg.read(spec(K1)).catch(() => {});
|
|
await clock.advance(0);
|
|
const html = view();
|
|
for (const id of ['refresh-all', 'health-card', 'check-again']) expect(html).toContain(`<button data-id="${id}" disabled="" aria-busy="true">spin</button>`);
|
|
expect(html).toContain('<button data-id="other-app" aria-busy="false">idle</button>');
|
|
// and the other way round: a broad read in flight makes the narrow buttons busy before any of them started
|
|
const g = setup();
|
|
g.reg.group({ key: READ_ALL_KEY, tags: ['all'], covers: [K3], run: () => new Promise(() => {}) }).catch(() => {});
|
|
await flush();
|
|
const html2 = renderToStaticMarkup(createElement(RegistryCtx.Provider, { value: g.reg }, createElement(Btn, { q: K3, id: 'card' }), createElement(Btn, { q: { keys: [K3] }, id: 'notice' })));
|
|
expect(html2).toContain('<button data-id="card" disabled="" aria-busy="true">spin</button>');
|
|
expect(html2).toContain('<button data-id="notice" disabled="" aria-busy="true">spin</button>');
|
|
});
|
|
});
|