Skip to content
markpaper

src/transport/cache.test.ts

v0.3.0 · 7.8 KB

Download file
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);
    }
  });
});
All files