src/transport/cache.test.ts
v0.3.0 · 7.8 KB
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createCachedLoader } from './cache.js';
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(1_000_000);
});
afterEach(() => {
vi.useRealTimers();
});
describe('createCachedLoader', () => {
it('single-flight: concurrent misses share one request (meta stampede)', async () => {
const load = vi.fn(async () => ({ universe: [] as unknown[] }));
const meta = createCachedLoader(load, { ttlMs: 300_000 });
const results = await Promise.all(Array.from({ length: 50 }, () => meta.get()));
expect(load).toHaveBeenCalledTimes(1);
expect(new Set(results).size).toBe(1);
});
it('serves fresh values without loading and reloads after TTL', async () => {
let n = 0;
const c = createCachedLoader(async () => ++n, { ttlMs: 1000, ttlJitter: 0 });
expect(await c.get()).toBe(1);
vi.advanceTimersByTime(999);
expect(await c.get()).toBe(1);
vi.advanceTimersByTime(1);
expect(await c.get()).toBe(2);
});
it('jitters TTL within +-20% once per write', async () => {
const lo = createCachedLoader(async () => 'v', { ttlMs: 1000, random: () => 0 });
const hi = createCachedLoader(async () => 'v', { ttlMs: 1000, random: () => 0.999999 });
await lo.get();
await hi.get();
vi.advanceTimersByTime(799);
expect(lo.peek()?.fresh).toBe(true);
vi.advanceTimersByTime(1);
expect(lo.peek()?.fresh).toBe(false);
vi.advanceTimersByTime(399);
expect(hi.peek()?.fresh).toBe(true);
vi.advanceTimersByTime(1);
expect(hi.peek()?.fresh).toBe(false);
});
it('keys are independent', async () => {
const load = vi.fn(async (k: string) => `state:${k}`);
const c = createCachedLoader(load, { ttlMs: 1000 });
expect(await c.get('a')).toBe('state:a');
expect(await c.get('b')).toBe('state:b');
expect(await c.get('a')).toBe('state:a');
expect(load).toHaveBeenCalledTimes(2);
});
it('does not cache errors: the next call retries ("could not read" is not "empty")', async () => {
let fail = true;
const c = createCachedLoader(
async () => {
if (fail) throw new Error('429');
return [] as number[];
},
{ ttlMs: 1000 },
);
await expect(c.get()).rejects.toThrow('429');
expect(c.peek()).toBeUndefined();
fail = false;
await expect(c.get()).resolves.toEqual([]);
expect(c.peek()?.fresh).toBe(true); // an empty array is a valid, cached value
});
it('failCooldownMs serves the last good value after a failure without hammering', async () => {
let fail = false;
const load = vi.fn(async () => {
if (fail) throw new Error('degraded');
return 'good';
});
const c = createCachedLoader(load, { ttlMs: 1000, ttlJitter: 0, failCooldownMs: 30_000 });
await c.get();
fail = true;
vi.advanceTimersByTime(1000);
expect(await c.get()).toBe('good');
expect(load).toHaveBeenCalledTimes(2);
for (let i = 0; i < 20; i++) expect(await c.get()).toBe('good');
expect(load).toHaveBeenCalledTimes(2);
expect(c.peek()?.fresh).toBe(false);
vi.advanceTimersByTime(30_000);
await c.get();
expect(load).toHaveBeenCalledTimes(3);
});
it('maxStaleMs bounds how old a served value may be', async () => {
let fail = false;
const c = createCachedLoader(
async () => {
if (fail) throw new Error('down');
return 'v';
},
{ ttlMs: 1000, ttlJitter: 0, failCooldownMs: 60_000, maxStaleMs: 5000 },
);
await c.get();
fail = true;
vi.advanceTimersByTime(6000);
await expect(c.get()).rejects.toThrow('down');
});
it('minRefreshIntervalMs survives invalidate() (event invalidation must not cause REST avalanches)', async () => {
const load = vi.fn(async () => 'positions');
const c = createCachedLoader(load, { ttlMs: 30_000, minRefreshIntervalMs: 10_000 });
await c.get();
for (let i = 0; i < 10; i++) {
c.invalidate();
vi.advanceTimersByTime(500);
await c.get();
}
expect(load).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(5000);
c.invalidate();
await c.get();
expect(load).toHaveBeenCalledTimes(2);
});
it('invalidate() without a floor forces a reload', async () => {
let n = 0;
const c = createCachedLoader(async () => ++n, { ttlMs: 60_000 });
await c.get();
c.invalidate();
expect(c.peek()?.fresh).toBe(false);
expect(await c.get()).toBe(2);
});
it('stale-while-revalidate returns the stale value and refreshes in the background', async () => {
let n = 0;
const onBackgroundError = vi.fn();
let fail = false;
const c = createCachedLoader(
async () => {
if (fail) throw new Error('bg');
return ++n;
},
{ ttlMs: 1000, ttlJitter: 0, staleWhileRevalidate: true, onBackgroundError },
);
expect(await c.get()).toBe(1);
vi.advanceTimersByTime(1000);
expect(await c.get()).toBe(1);
expect(c.isLoading()).toBe(true);
await vi.advanceTimersByTimeAsync(0);
expect(await c.get()).toBe(2);
fail = true;
vi.advanceTimersByTime(1000);
expect(await c.get()).toBe(2);
await vi.advanceTimersByTimeAsync(0);
expect(onBackgroundError).toHaveBeenCalledWith(expect.any(Error), '');
});
it('delete() during an in-flight load drops the late result (subject changed)', async () => {
let resolveLoad!: (v: string) => void;
const c = createCachedLoader(() => new Promise<string>((r) => (resolveLoad = r)), { ttlMs: 1000 });
const p = c.get('slot');
await vi.advanceTimersByTimeAsync(0);
c.delete('slot');
resolveLoad('account A');
await expect(p).resolves.toBe('account A');
expect(c.peek('slot')).toBeUndefined();
});
it('a synchronously throwing loader does not leave a stuck in-flight promise', async () => {
let calls = 0;
const c = createCachedLoader(
(() => {
calls++;
if (calls === 1) throw new Error('sync');
return Promise.resolve('ok');
}) as () => Promise<string>,
{ ttlMs: 1000 },
);
await expect(c.get()).rejects.toThrow('sync');
expect(c.isLoading()).toBe(false);
await expect(c.get()).resolves.toBe('ok');
});
it('clear() empties the cache and ttlMs is validated', async () => {
const c = createCachedLoader(async () => 1, { ttlMs: 1000 });
await c.get('a');
c.clear();
expect(c.peek('a')).toBeUndefined();
expect(() => createCachedLoader(async () => 1, { ttlMs: -1 })).toThrow(RangeError);
});
});
describe('createCachedLoader edge cases', () => {
it('caches an empty array as a valid value', async () => {
const load = vi.fn(async () => [] as unknown[]);
const c = createCachedLoader(load, { ttlMs: 10_000 });
expect(await c.get('0xYOUR_ADDRESS')).toEqual([]);
expect(await c.get('0xYOUR_ADDRESS')).toEqual([]);
expect(load).toHaveBeenCalledTimes(1);
});
it('stale-while-revalidate with failCooldownMs stops re-requesting during a degradation', async () => {
let fail = false;
const load = vi.fn(async () => {
if (fail) throw new Error('degraded');
return 'meta';
});
const c = createCachedLoader(load, { ttlMs: 1000, ttlJitter: 0, staleWhileRevalidate: true, failCooldownMs: 30_000 });
await c.get();
fail = true;
vi.advanceTimersByTime(1000);
for (let i = 0; i < 20; i++) {
expect(await c.get()).toBe('meta');
await vi.advanceTimersByTimeAsync(10);
}
expect(load).toHaveBeenCalledTimes(2);
});
it('with ttlJitter 0.2 a TTL never leaves [0.8, 1.2] x base even at random extremes', async () => {
for (const r of [0, 0.999999]) {
vi.setSystemTime(1_000_000);
let n = 0;
const c = createCachedLoader(async () => ++n, { ttlMs: 10_000, random: () => r });
await c.get();
vi.advanceTimersByTime(7_999);
expect(await c.get()).toBe(1);
vi.advanceTimersByTime(4_002);
expect(await c.get()).toBe(2);
}
});
});