Skip to content
markpaper

src/markets/cache.test.ts

v0.2.0 · 5.9 KB

Download file
import { describe, expect, it } from 'vitest';
import {
  createSymbolsCache,
  DEFAULT_SYMBOLS_FAIL_COOLDOWN_MS,
  DEFAULT_SYMBOLS_TTL_MS,
  FRESH_TTL_MULTIPLIER,
  type SymbolsCacheEvent,
  SymbolsUnstableError,
} from './cache.js';
import { BTC_RAW, PONS_RAW, symbolsPayload as payload, XRP_RAW } from './fixtures.js';

function clock(start = 0) {
  let t = start;
  const advance = (ms: number): void => {
    t += ms;
  };
  return { now: () => t, advance };
}

const base = () => payload({ 'BTC-PERP': BTC_RAW, 'XRP-PERP': XRP_RAW });

describe('createSymbolsCache', () => {
  it('defaults: TTL 5 min, cooldown 30 s, fresh window 2 TTL', () => {
    expect(DEFAULT_SYMBOLS_TTL_MS).toBe(300_000);
    expect(DEFAULT_SYMBOLS_FAIL_COOLDOWN_MS).toBe(30_000);
    expect(FRESH_TTL_MULTIPLIER).toBe(2);
    expect(() => createSymbolsCache({ load: async () => base(), ttlMs: 0 })).toThrow(RangeError);
  });

  it('loads once within the TTL and single-flights concurrent callers', async () => {
    const c = clock();
    let loads = 0;
    const cache = createSymbolsCache({
      load: async () => {
        loads++;
        return base();
      },
      now: c.now,
    });
    expect(cache.peek()).toBeNull();
    expect(cache.isFresh()).toBe(false);
    const [a, b] = await Promise.all([cache.get(), cache.get()]);
    expect(a).toBe(b);
    expect(loads).toBe(1);
    expect((await cache.resolve('BTC'))?.productId).toBe(2);
    expect(await cache.resolve('NOPE')).toBeUndefined();
    c.advance(DEFAULT_SYMBOLS_TTL_MS - 1);
    await cache.get();
    expect(loads).toBe(1);
    c.advance(2);
    await cache.get();
    expect(loads).toBe(2);
    expect(cache.status()).toMatchObject({ loaded: true, fresh: true, count: 2, consecutiveUnstable: 0 });
  });

  it('serves stale on a failed refresh, backs off, and loses freshness after two TTLs', async () => {
    const c = clock();
    let fail = false;
    let loads = 0;
    const events: SymbolsCacheEvent[] = [];
    const cache = createSymbolsCache({
      load: async () => {
        loads++;
        if (fail) throw new Error('boom');
        return base();
      },
      now: c.now,
      onEvent: (e) => events.push(e),
    });
    const first = await cache.get();
    fail = true;
    c.advance(DEFAULT_SYMBOLS_TTL_MS + 1);
    expect(await cache.get()).toBe(first); // stale served
    expect(loads).toBe(2);
    expect(cache.status().lastError).toBeInstanceOf(Error);
    expect(events.at(-1)).toMatchObject({ type: 'refresh-failed', servingStale: true });
    // cooldown: no new attempt for 30 s
    c.advance(DEFAULT_SYMBOLS_FAIL_COOLDOWN_MS - 1);
    await cache.get();
    expect(loads).toBe(2);
    c.advance(2);
    await cache.get();
    expect(loads).toBe(3);
    // one failure is forgiven, two TTLs are not
    expect(cache.isFresh()).toBe(true);
    c.advance(DEFAULT_SYMBOLS_TTL_MS);
    expect(cache.isFresh()).toBe(false);
    expect(cache.status().fresh).toBe(false);
    // a successful refresh restores freshness
    fail = false;
    c.advance(DEFAULT_SYMBOLS_FAIL_COOLDOWN_MS + 1);
    await cache.get();
    expect(cache.isFresh()).toBe(true);
    expect(cache.status().lastError).toBeUndefined();
  });

  it('throws on the first load failure (nothing to serve stale)', async () => {
    const cache = createSymbolsCache({ load: async () => ({ symbols: {} }) });
    await expect(cache.get()).rejects.toThrow(/no perp products/);
    expect(cache.peek()).toBeNull();
  });

  it('rejects an unstable refresh, keeps serving the previous universe and counts the streak', async () => {
    const c = clock();
    let current: unknown = base();
    const events: SymbolsCacheEvent[] = [];
    const cache = createSymbolsCache({ load: async () => current, now: c.now, onEvent: (e) => events.push(e) });
    const first = await cache.get();
    // rename XRP -> XRPX: the old coin drops out of the universe, as after a public market rename
    current = payload({ 'BTC-PERP': BTC_RAW, 'XRPX-PERP': { ...XRP_RAW, symbol: 'XRPX-PERP' }, 'PONS-PERP': PONS_RAW });
    for (let i = 1; i <= 3; i++) {
      c.advance(DEFAULT_SYMBOLS_TTL_MS + DEFAULT_SYMBOLS_FAIL_COOLDOWN_MS + 1);
      expect(await cache.get()).toBe(first);
      expect(cache.status().consecutiveUnstable).toBe(i);
      expect(cache.status().lastError).toBeInstanceOf(SymbolsUnstableError);
    }
    expect(events.filter((e) => e.type === 'unstable')).toHaveLength(3);
    expect(cache.peek()?.byCoin.has('PONS')).toBe(false); // frozen: new listing invisible
  });

  it('with pinnedCoins a dropped unpinned coin does not freeze the cache', async () => {
    const c = clock();
    let current: unknown = base();
    const events: SymbolsCacheEvent[] = [];
    const cache = createSymbolsCache({
      load: async () => current,
      now: c.now,
      pinnedCoins: () => ['BTC'],
      onEvent: (e) => events.push(e),
    });
    await cache.get();
    current = payload({ 'BTC-PERP': BTC_RAW, 'XRPX-PERP': { ...XRP_RAW, symbol: 'XRPX-PERP' }, 'PONS-PERP': PONS_RAW });
    c.advance(DEFAULT_SYMBOLS_TTL_MS + 1);
    const u = await cache.get();
    expect(u.byCoin.has('PONS')).toBe(true);
    expect(u.byCoin.has('XRP')).toBe(false);
    expect(events.at(-1)).toMatchObject({ type: 'refreshed', dropped: ['XRP'], added: ['XRPX', 'PONS'] });
  });

  it('invalidate forces the next get() to refresh', async () => {
    const c = clock();
    let loads = 0;
    const cache = createSymbolsCache({
      load: async () => {
        loads++;
        return base();
      },
      now: c.now,
    });
    await cache.get();
    await cache.get();
    expect(loads).toBe(1);
    cache.invalidate();
    await cache.get();
    expect(loads).toBe(2);
    await cache.get();
    expect(loads).toBe(2);
  });

  it('swallows exceptions from the event handler', async () => {
    const cache = createSymbolsCache({
      load: async () => base(),
      onEvent: () => {
        throw new Error('handler');
      },
    });
    await expect(cache.get()).resolves.toBeDefined();
  });
});
All files