Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9f78b69
fix(graphile-cache): bound instance cache by heap, not a fixed count …
yyyyaaa Jun 30, 2026
9ea25e3
feat(graphile-cache,server): eviction drain, build admission control,…
yyyyaaa Jul 2, 2026
d320a27
fix(graphile-i18n): run translations query with request pgSettings (R…
yyyyaaa Jul 2, 2026
f62fb69
fix(graphile-settings): key meta-schema tables meta by build object, …
yyyyaaa Jul 2, 2026
0d1496e
feat(server,graphile): opt-in blueprint pooling — one shared instance…
yyyyaaa Jul 2, 2026
b3bad9e
fix(server,graphile-settings): W3 verification + adversarial review f…
yyyyaaa Jul 2, 2026
2eeaefb
feat(server,graphile-cache): env-gated metrics instrumentation + scal…
yyyyaaa Jul 2, 2026
ea1275b
chore: ignore scale-validate output artifacts
yyyyaaa Jul 2, 2026
143f479
feat(scale-validate): V2 ramp tooling + catalog-wall remediation
yyyyaaa Jul 2, 2026
fd24ce2
fix(scale-validate): drift-tenants pg resolution + per-step serverEnv…
yyyyaaa Jul 2, 2026
d641056
fix(scale-validate): PG-crash-resilient sampler + tuned/heap-floor V2…
yyyyaaa Jul 2, 2026
620b011
fix(graphile-cache): heap-aware cap floor 3 -> 1 (over-admission SIGA…
yyyyaaa Jul 2, 2026
4659d52
feat(server): connection-class error guard — PG restart must not kill…
yyyyaaa Jul 2, 2026
bf86ef3
chore(scale-validate): results summarizer for v2-ramps JSONL
yyyyaaa Jul 2, 2026
8ef7274
docs(scale-validate): V2 limits-discovery results + soak ops cycler +…
yyyyaaa Jul 2, 2026
9883e7e
docs(scale-validate): V2 complete — capacity-2 proof + single-bluepri…
yyyyaaa Jul 2, 2026
1049ec3
fix(scale-validate): churn-driver per-query connections (57P05 idle r…
yyyyaaa Jul 2, 2026
6745ea2
feat(graphile-cache,server): memory governor + capacity model + /metr…
yyyyaaa Jul 2, 2026
8eec3cd
docs(scale-validate): SIZING.md — catalog-driven capacity model + sha…
yyyyaaa Jul 2, 2026
1d8849c
fix(scale-validate): clean pg_partman registry on tenant teardown
yyyyaaa Jul 2, 2026
aeb8c35
docs(scale-validate): consolidated validation report (V1-V4, findings…
yyyyaaa Jul 2, 2026
b468b15
fix(graphile-cache,server): drain-aware build admission + per-databas…
yyyyaaa Jul 3, 2026
f138573
feat(perf-harness): reusable perf/regression harness package
yyyyaaa Jul 3, 2026
acc035c
docs(scale): V3 soak PASS verdict + finding 11 in validation report
yyyyaaa Jul 3, 2026
0e0669a
chore(lockfile): add perf-harness workspace importer
yyyyaaa Jul 3, 2026
2ef2f0d
fix(perf-harness): create parent dirs at all file-write sites
yyyyaaa Jul 3, 2026
e545965
feat(perf-harness): deep regression scenarios for the corrected scali…
yyyyaaa Jul 3, 2026
445d4c3
feat(server,graphile): qualified-SQL blueprint pooling — canonical→te…
yyyyaaa Jul 4, 2026
7d1a139
fix(perf-harness): deep-scenario hardening — LIKE-escaped partman loo…
yyyyaaa Jul 4, 2026
7615a8c
feat(server): namespace-scoped catalog introspection at the pool seam…
yyyyaaa Jul 4, 2026
e38f4b5
fix(server): key settings-query classification on the adaptor stateme…
yyyyaaa Jul 6, 2026
dd077ba
chore(scale-validate): env-only seeder credentials + ignore rig artif…
yyyyaaa Jul 6, 2026
46636d4
fix(server,graphile-cache): eviction-churn OOM — clear build-wait rac…
yyyyaaa Jul 6, 2026
f95d59f
docs(scale-validate): capacity-10x program results — filtered constan…
yyyyaaa Jul 6, 2026
3088062
fix(server,graphile-cache,scale-validate): final-sweep fixes — unifor…
yyyyaaa Jul 6, 2026
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
186 changes: 186 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,186 @@
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();
// Each cached instance retains ~0.5GB, so the default must be far below the legacy 50
// on a normal heap, but always within [3, 50].
expect(max).toBeGreaterThanOrEqual(3);
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(3);
expect(max).toBeLessThanOrEqual(50);
});
});

// 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);
});
});
9 changes: 8 additions & 1 deletion graphile/graphile-cache/src/create-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ const log = new Logger('graphile-cache:create');
interface GraphileInstanceOptions {
preset: any;
cacheKey: string;
/**
* Database name backing this instance's pg pool. Stored on the entry so the
* graphile-cache pgCache cleanup callback can evict it when its pool is disposed.
*/
dbname?: string;
/**
* When true, a RealtimeManager is created and started alongside the
* PostGraphile instance. The pool is extracted from the preset's
Expand Down Expand Up @@ -37,7 +42,7 @@ interface GraphileInstanceOptions {
export const createGraphileInstance = async (
opts: GraphileInstanceOptions
): Promise<GraphileCacheEntry> => {
const { preset, cacheKey, enableRealtime = false } = opts;
const { preset, cacheKey, dbname, enableRealtime = false } = opts;

const pgl = postgraphile(preset);
const serv = pgl.createServ(grafserv);
Expand All @@ -53,7 +58,9 @@ export const createGraphileInstance = async (
handler,
httpServer,
cacheKey,
dbname,
createdAt: Date.now(),
inflight: 0,
};

if (enableRealtime) {
Expand Down
Loading
Loading