Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@ postgres/pgsql-test/output/
.env.local
graphql/server/logs/
graphql/server/*.heapsnapshot
scripts/scale-validate/out/
scripts/scale-validate/fleet*.json
scripts/scale-validate/drifted.json
scripts/scale-spike/
packages/perf-harness/perf-out/
/metrics.jsonl
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