Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions graphile/graphile-cache/src/__tests__/counters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {
cacheCounters,
clearMatchingEntries,
getCacheConfig,
getCacheCounters,
graphileCache,
type GraphileCacheEntry
} from '../graphile-cache';

/**
* Counter instrumentation regression tests. The counters are process-global and
* mutable, so every assertion is a DELTA against a snapshot captured immediately
* before the action — robust to test ordering and to fire-and-forget disposals from
* other suites. The eviction and disposal increments happen synchronously inside
* graphileCache.delete() (the dispose callback and the pre-await head of disposeEntry),
* so no tick is needed except for the async drain-timeout path.
*/

const tick = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms));

const makeEntry = (releaseDelayMs = 0): GraphileCacheEntry => {
const release = jest.fn(
() => new Promise<void>((resolve) => setTimeout(resolve, releaseDelayMs))
);
return {
pgl: { release } as unknown as GraphileCacheEntry['pgl'],
serv: {} as GraphileCacheEntry['serv'],
handler: {} as GraphileCacheEntry['handler'],
// Never .listen()ed in production; close() invokes its callback synchronously here.
httpServer: { close: (cb: () => void) => cb() } as unknown as GraphileCacheEntry['httpServer'],
cacheKey: 'mock',
createdAt: Date.now()
};
};

describe('cacheCounters increment on eviction/disposal', () => {
afterEach(async () => {
graphileCache.clear();
await tick();
});

it('counts an LRU eviction (fresh entry, plain delete) and a disposal', () => {
const before = getCacheCounters();
const key = 'counter-lru-1';
graphileCache.set(key, makeEntry(0));
graphileCache.delete(key); // fresh + not manual → reason 'lru'; disposeEntry runs

const after = getCacheCounters();
expect(after.evictions.lru).toBe(before.evictions.lru + 1);
expect(after.disposals).toBe(before.disposals + 1);
});

it('counts a TTL eviction when the entry is older than the configured ttl', () => {
const before = getCacheCounters();
const key = 'counter-ttl-1';
const entry = makeEntry(0);
// Backdate our own createdAt beyond the idle-ttl so getEvictionReason returns 'ttl'.
entry.createdAt = Date.now() - getCacheConfig().ttl - 1_000;
graphileCache.set(key, entry);
graphileCache.delete(key);

const after = getCacheCounters();
expect(after.evictions.ttl).toBe(before.evictions.ttl + 1);
});

it('counts a manual eviction via clearMatchingEntries', () => {
const before = getCacheCounters();
graphileCache.set('counter-manual-1', makeEntry(0));
const cleared = clearMatchingEntries(/^counter-manual-/);

expect(cleared).toBe(1);
const after = getCacheCounters();
expect(after.evictions.manual).toBe(before.evictions.manual + 1);
});
});

describe('cacheCounters.drainTimeouts', () => {
const prev = process.env.GRAPHILE_CACHE_DRAIN_TIMEOUT_MS;
afterEach(async () => {
if (prev === undefined) delete process.env.GRAPHILE_CACHE_DRAIN_TIMEOUT_MS;
else process.env.GRAPHILE_CACHE_DRAIN_TIMEOUT_MS = prev;
graphileCache.clear();
await tick();
});

it('increments when disposal drains past the timeout with a request still in flight', async () => {
process.env.GRAPHILE_CACHE_DRAIN_TIMEOUT_MS = '1'; // one 100ms poll cycle, then give up
const before = getCacheCounters();
const key = 'counter-drain-1';
const entry = makeEntry(0);
entry.inflight = 1; // never drops → drain loop hits the timeout branch

graphileCache.set(key, entry);
graphileCache.delete(key); // fire-and-forget disposeEntry begins draining

await tick(250); // allow the poll cycle + timeout to elapse

const after = getCacheCounters();
expect(after.drainTimeouts).toBe(before.drainTimeouts + 1);
// Disposal still proceeds after the drain timeout.
expect((entry.pgl as unknown as { release: jest.Mock }).release).toHaveBeenCalledTimes(1);
});
});

describe('getCacheCounters snapshot isolation', () => {
it('returns a deep copy that cannot mutate the live counters', () => {
const before = getCacheCounters();
const snapshot = getCacheCounters();
snapshot.disposals += 1_000;
snapshot.evictions.lru += 1_000;
snapshot.drainTimeouts += 1_000;

const after = getCacheCounters();
expect(after.disposals).toBe(before.disposals);
expect(after.evictions.lru).toBe(before.evictions.lru);
expect(after.drainTimeouts).toBe(before.drainTimeouts);
// Sanity: the live object is the same instance across reads.
expect(cacheCounters.disposals).toBe(after.disposals);
});
});
201 changes: 201 additions & 0 deletions graphile/graphile-cache/src/__tests__/disposal-and-cap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { EventEmitter } from 'events';
import {
ensureCacheHeadroom,
getCacheConfig,
graphileCache,
type GraphileCacheEntry,
invokeEntryHandler,
} from '../graphile-cache';

/**
* Regression tests for the schema-builder OOM fix.
*
* 1. Disposal guard is entry-scoped, not key-scoped. The previous string-keyed guard
* skipped pgl.release() for a rebuilt entry that shared a key with an entry still
* mid-release (the "same-key disposal race"). These tests pin the corrected behaviour:
* every distinct entry is released exactly once, while the SAME entry is never
* double-disposed.
* 2. getCacheConfig honours GRAPHILE_CACHE_MAX and otherwise returns a heap-aware default
* bounded to a sane range (a count-only default of 50 × ~0.5GB/instance was the OOM).
*/

const tick = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms));

const makeEntry = (releaseDelayMs = 0): GraphileCacheEntry => {
const release = jest.fn(
() => new Promise<void>((resolve) => setTimeout(resolve, releaseDelayMs)),
);
return {
pgl: { release } as unknown as GraphileCacheEntry['pgl'],
serv: {} as GraphileCacheEntry['serv'],
handler: {} as GraphileCacheEntry['handler'],
// Never .listen()ed in production; close() invokes its callback synchronously here.
httpServer: { close: (cb: () => void) => cb() } as unknown as GraphileCacheEntry['httpServer'],
cacheKey: 'mock',
createdAt: Date.now(),
};
};

describe('graphile-cache disposal guard (same-key race)', () => {
it('disposes a rebuilt entry on the same key while a prior entry is mid-release', async () => {
const key = 'race-key-1';
const a = makeEntry(40); // a.release() stays pending while b is disposed
const b = makeEntry(0);

graphileCache.set(key, a);
graphileCache.delete(key); // -> disposeEntry(a) begins, a.release() in flight
graphileCache.set(key, b); // key was free; inserts b
graphileCache.delete(key); // -> disposeEntry(b) MUST NOT be skipped by the guard

await tick(120);

// The bug: the old string-keyed guard left `key` parked while a.release() was pending,
// so b.release() was never called (would be 0 here).
expect((a.pgl as unknown as { release: jest.Mock }).release).toHaveBeenCalledTimes(1);
expect((b.pgl as unknown as { release: jest.Mock }).release).toHaveBeenCalledTimes(1);
});

it('never disposes the same entry twice', async () => {
const key = 'race-key-2';
const a = makeEntry(0);

graphileCache.set(key, a);
graphileCache.delete(key); // dispose a
graphileCache.set(key, a); // re-insert the SAME entry object
graphileCache.delete(key); // dispose again -> guard must skip (already disposed)

await tick();

expect((a.pgl as unknown as { release: jest.Mock }).release).toHaveBeenCalledTimes(1);
});
});

describe('graphile-cache heap-aware capacity', () => {
const prev = process.env.GRAPHILE_CACHE_MAX;
afterEach(() => {
if (prev === undefined) delete process.env.GRAPHILE_CACHE_MAX;
else process.env.GRAPHILE_CACHE_MAX = prev;
});

it('honours an explicit GRAPHILE_CACHE_MAX', () => {
process.env.GRAPHILE_CACHE_MAX = '7';
expect(getCacheConfig().max).toBe(7);
});

it('falls back to a bounded heap-aware default when unset', () => {
delete process.env.GRAPHILE_CACHE_MAX;
const { max } = getCacheConfig();
// Bounded within [1, 50]: at least one instance must be admitted, but the
// floor must never exceed what the heap budget says fits (a floor of 3
// over-admitted 1.3GB instances on a 2GB heap and aborted the process).
expect(max).toBeGreaterThanOrEqual(1);
expect(max).toBeLessThanOrEqual(50);
});

it('ignores a non-numeric GRAPHILE_CACHE_MAX (falls back to heap-aware default)', () => {
process.env.GRAPHILE_CACHE_MAX = 'not-a-number';
const { max } = getCacheConfig();
expect(max).toBeGreaterThanOrEqual(1);
expect(max).toBeLessThanOrEqual(50);
});

it('admits only one instance when the per-instance estimate exceeds half the heap', () => {
delete process.env.GRAPHILE_CACHE_MAX;
const prevEstimate = process.env.GRAPHILE_CACHE_INSTANCE_HEAP_BYTES;
// Larger than any plausible test-runner heap budget => budgeted count is 0
// and the floor of 1 must win (never the old floor of 3).
process.env.GRAPHILE_CACHE_INSTANCE_HEAP_BYTES = String(64 * 1024 * 1024 * 1024);
try {
expect(getCacheConfig().max).toBe(1);
} finally {
if (prevEstimate === undefined) delete process.env.GRAPHILE_CACHE_INSTANCE_HEAP_BYTES;
else process.env.GRAPHILE_CACHE_INSTANCE_HEAP_BYTES = prevEstimate;
}
});
});

// A minimal express-like response: an EventEmitter so invokeEntryHandler can hook
// 'finish'/'close', cast to the express type it expects.
const makeRes = () => new EventEmitter() as unknown as Parameters<typeof invokeEntryHandler>[2];
const fakeReq = {} as Parameters<typeof invokeEntryHandler>[1];
const fakeNext = (() => undefined) as Parameters<typeof invokeEntryHandler>[3];

describe('invokeEntryHandler in-flight refcounting', () => {
it('increments while serving and releases exactly once across finish+close', () => {
const entry = makeEntry();
const handler = jest.fn();
entry.handler = handler as unknown as GraphileCacheEntry['handler'];

const res = makeRes();
expect(invokeEntryHandler(entry, fakeReq, res, fakeNext)).toBe(true);
expect(handler).toHaveBeenCalledTimes(1);
expect(entry.inflight).toBe(1);

(res as unknown as EventEmitter).emit('finish');
expect(entry.inflight).toBe(0);
// 'close' always follows 'finish' in Node; the release must be idempotent.
(res as unknown as EventEmitter).emit('close');
expect(entry.inflight).toBe(0);
});

it('refuses a disposing entry without invoking the handler', () => {
const entry = makeEntry();
const handler = jest.fn();
entry.handler = handler as unknown as GraphileCacheEntry['handler'];
entry.disposing = true;

expect(invokeEntryHandler(entry, fakeReq, makeRes(), fakeNext)).toBe(false);
expect(handler).not.toHaveBeenCalled();
});
});

describe('disposeEntry drains in-flight requests before release', () => {
it('waits for inflight to reach 0, then releases exactly once', async () => {
const key = 'drain-key-1';
const entry = makeEntry(0);
entry.inflight = 1; // simulate a request mid-flight

graphileCache.set(key, entry);
graphileCache.delete(key); // disposal starts; must poll instead of releasing

await tick(120); // one poll cycle elapsed, request still in flight
expect((entry.pgl as unknown as { release: jest.Mock }).release).not.toHaveBeenCalled();

entry.inflight = 0; // request completes
await tick(250); // allow the next poll cycle to observe it

expect((entry.pgl as unknown as { release: jest.Mock }).release).toHaveBeenCalledTimes(1);
});
});

describe('ensureCacheHeadroom', () => {
const prevMax = process.env.GRAPHILE_CACHE_MAX;
afterEach(() => {
if (prevMax === undefined) delete process.env.GRAPHILE_CACHE_MAX;
else process.env.GRAPHILE_CACHE_MAX = prevMax;
graphileCache.clear();
});

it('evicts least-recently-used entries until a build slot is free', async () => {
process.env.GRAPHILE_CACHE_MAX = '2';
const a = makeEntry(0);
const b = makeEntry(0);
graphileCache.set('headroom-a', a);
graphileCache.set('headroom-b', b);
graphileCache.get('headroom-a'); // touch a → b becomes LRU-oldest

const evicted = ensureCacheHeadroom(1);

expect(evicted).toBe(1);
expect(graphileCache.has('headroom-a')).toBe(true);
expect(graphileCache.has('headroom-b')).toBe(false);
await tick(); // let the fire-and-forget disposal settle before clear()
});

it('no-ops when under capacity', () => {
process.env.GRAPHILE_CACHE_MAX = '5';
graphileCache.set('headroom-c', makeEntry(0));
expect(ensureCacheHeadroom(1)).toBe(0);
expect(graphileCache.has('headroom-c')).toBe(true);
});
});
Loading
Loading