Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7a57b42
add Graphile readiness and disposal primitives
Zetazzz Aug 16, 2026
e7d7636
fix(graphile-cache): await subscriber shutdown and failed build settl…
Zetazzz Sep 9, 2026
1c9dfb1
Remove dataplan release patch and validate connection cleanup
Zetazzz Sep 21, 2026
2752c56
Merge main and measure actual service release calls
Zetazzz Sep 21, 2026
9f64b13
Bind Graphile cache reuse to the exact build contract
Zetazzz Sep 21, 2026
c83256e
Admit Graphile residents within count and heap budgets
Zetazzz Sep 21, 2026
f61aca9
Coalesce exact Graphile builds and fence invalidated generations
Zetazzz Sep 21, 2026
66f1caa
fix(graphile-cache): ignore missing service invalidation keys
Zetazzz Sep 21, 2026
dc2d17b
Merge F17 review fix and document failed-disposal admission fence
Zetazzz Sep 21, 2026
4931d07
Merge reviewed admission dependency into Graphile build coalescing
Zetazzz Sep 21, 2026
89f4561
fix(graphile-cache): preserve cleanup failures across disposal drains
Zetazzz Sep 22, 2026
15b2add
fix(graphql-server): reuse request context and preserve canonical bui…
Zetazzz Sep 22, 2026
d43e6d3
fix(graphile-cache): centralize configuration and honor resident budgets
Zetazzz Sep 22, 2026
50bf913
fix(graphile-cache): carry CNC ownership through shared build flights
Zetazzz Sep 22, 2026
36841eb
test(graphql-server): apply fixture transaction hooks in the F17 layer
Zetazzz Sep 22, 2026
71ead47
test(graphile-cache): inherit F17 fixture transaction isolation
Zetazzz Sep 22, 2026
f1aa085
test(graphile-cache): preserve fixture hooks across build-flight stack
Zetazzz Sep 22, 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
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ jobs:
- batch: pg-graphql
packages: 'graphile/graphile-search graphile/graphile-ltree graphile/graphile-bulk-mutations graphile/graphile-function-bindings graphile/graphile-history graphile/graphile-meta graphile/graphile-schema graphql/orm-test graphql/test graphql/playwright-test'
- batch: pg-graphile-extras
packages: 'graphile/graphile-i18n graphile/graphile-pg-aggregates graphile/graphile-query graphile/graphile-realtime-test'
packages: 'graphile/graphile-cache graphile/graphile-i18n graphile/graphile-pg-aggregates graphile/graphile-query graphile/graphile-realtime-test'

env:
PGHOST: localhost
Expand Down
70 changes: 70 additions & 0 deletions graphile/graphile-cache/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,76 @@ When you import this package, it automatically registers a cleanup callback with

## Usage

### Exact build identity

The GraphQL server and Explorer cache by `createGraphileBuildCacheKey(domain,
snapshot)`. A key includes the effective build inputs, the exact pool reference,
and the configuration owner. Server and Explorer use separate domains. Plain
data is copied with `snapshotGraphileBuildValue` before it is used for both the
fingerprint and the preset; opaque objects and functions retain reference
identity. Keys are HMAC fingerprints with a process-local secret and must not be
persisted or compared across processes.

Entries carry `serviceKey`, `databaseId` (when known), and `poolKey` separately
from the fingerprint. Use `clearGraphileEntriesForService`,
`clearGraphileEntriesForDatabase`, or `clearGraphileEntriesForPool` to invalidate
all build variants for that owner. Pool cleanup matches `poolKey` exactly.

The server consumes the API, physical pool, service key, and transaction settings
from `req.constructive`. Request settings remain request-scoped and are not
captured in the shared schema. Build refusals retain their canonical error code
and HTTP status; unknown failures return a generic 500 response with the original
cause kept internally.

Bulk disposal drains report all failures for generations scheduled before the
call. Work scheduled later belongs to the next drain. An observed bulk failure
is acknowledged for reporting, while its exact-entry release promise remains
rejected; acknowledging an error never establishes successful resource release.

### Resident admission

Server and Explorer builds use `buildAdmittedGraphileInstance(metadata, factory)`.
It reserves capacity before the factory allocates services, waits for an evicted
entry's public release interfaces, and publishes only after the ready result passes its heap check.
Upstream background UNLISTEN/client return is outside that public release boundary.
The count includes reservations, residents, and disposal in progress. A disposal
failure blocks further admission instead of treating uncertain resources as free.
This process-wide fence requires a process restart. Disposal counters track pending
work and can reach zero after a rejected release; neither emptying the cache nor
reconfiguring limits proves that the failed release reclaimed its resources.
The public release interfaces provide no verified retry-completion signal, so
automatically reopening admission would abandon the capacity guarantee.

`configureGraphileAdmission` accepts `max`, `ttl`, `heapMaxBytes`, and
`buildReserveBytes`. The server exposes these as `graphile.cache` options and
`GRAPHILE_CACHE_MAX`, `GRAPHILE_CACHE_TTL_MS`, `GRAPHILE_CACHE_HEAP_MAX_BYTES`, and
`GRAPHILE_CACHE_BUILD_RESERVE_BYTES`. `graphql-env` owns environment parsing and
defaults; runtime options override environment and file configuration. This
package reads no environment variables. Defaults retain the 50-entry ceiling,
use 85% of the V8 heap limit as a watermark, and reserve 64 MiB per pending build.
The first owner can replace the fallback limits, including raising the resident
ceiling above 50. Later owners use the strictest limits. TTL changes require an
empty cache; lowering capacity below owned residents/reservations is rejected.
TTL expiry remains lazy, with expired entries reclaimed before admission. This is a conservative
process-heap admission check, not an exact per-instance memory measurement or an
RSS limit; garbage collection may delay admission after eviction. Raw
`graphileCache.set` is a low-level API and does not reserve capacity.

### Exact-key build flights

Server and Explorer use the shared `graphileBuildFlights.getOrCreate` owner.
It registers a flight synchronously, shares its promise for an exact build key,
and publishes through resident admission only after readiness. A failed flight
is shared with its current callers; a later request may start a new attempt.
There is no implicit retry by joined callers.

Preparation scopes fence asynchronous metadata lookup before the exact key is
known. Service/database/pool invalidation, key deletion, and cache clear fence
old work before removing residents. Late results are disposed instead of
published, and cannot erase a replacement flight. Closing caches rejects callers
promptly while draining the underlying work and its cleanup before pool close.
`reopenGraphileBuilds` only reopens a fully drained registry.

### Basic Usage

```typescript
Expand Down
3 changes: 2 additions & 1 deletion graphile/graphile-cache/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ module.exports = {
'ts-jest',
{
babelConfig: false,
tsconfig: 'tsconfig.json',
// ts-jest requires isolated transformation for NodeNext package exports.
tsconfig: { isolatedModules: true },
},
],
},
Expand Down
3 changes: 2 additions & 1 deletion graphile/graphile-cache/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"test:watch": "jest --watch"
},
"dependencies": {
"12factor-env": "workspace:^",
"@constructive-io/errors": "workspace:^",
"@pgpmjs/logger": "workspace:^",
"express": "^5.2.1",
"grafserv": "1.0.1",
Expand All @@ -42,6 +42,7 @@
"@types/express": "^5.0.6",
"makage": "^0.8.0",
"nodemon": "^3.1.14",
"pgsql-test": "workspace:^",
"ts-node": "^10.9.2"
},
"keywords": [
Expand Down
86 changes: 86 additions & 0 deletions graphile/graphile-cache/src/__tests__/admission.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { GraphileAdmission } from '../admission';

const deferred = () => {
let resolve!: () => void;
const promise = new Promise<void>((r) => { resolve = r; });
return { promise, resolve };
};

describe('resident admission', () => {
it('lets the first owner replace fallback count and heap limits', () => {
const admission = new GraphileAdmission({ occupied: () => 0, evict: async () => false }, 10, () => 10, 100);
admission.configure({ max: 20, heapMaxBytes: 200, buildReserveBytes: 10 });
expect(admission.stats).toMatchObject({ max: 20, heapMaxBytes: 200 });
admission.configure({ max: 30, heapMaxBytes: 300 });
expect(admission.stats).toMatchObject({ max: 20, heapMaxBytes: 200 });
});

it('does not reuse capacity until eviction completes, including concurrent admissions', async () => {
const disposal = deferred();
let occupied = 1;
const evict = jest.fn(async () => {
if (!occupied) return false;
await disposal.promise;
occupied--;
return true;
});
const admission = new GraphileAdmission({ occupied: () => occupied, evict }, 1, () => 10, 100);
admission.configure({ buildReserveBytes: 10 });
let admitted = false;
const first = admission.reserve().then((reservation) => { admitted = true; return reservation; });
const second = admission.reserve();
const refusal = expect(second).rejects.toMatchObject({ code: 'SCHEMA_CAPACITY_EXHAUSTED' });
await Promise.resolve();
expect(admitted).toBe(false);
disposal.resolve();
const reservation = await first;
await refusal;
expect(admission.stats.reserved).toBe(1);
reservation.release();
reservation.release();
expect(admission.stats.reserved).toBe(0);
});

it('reserves independent build headroom before admitting concurrent builders', async () => {
const admission = new GraphileAdmission({ occupied: () => 0, evict: async () => false }, 10, () => 60, 100);
admission.configure({ buildReserveBytes: 30 });
const first = await admission.reserve();
await expect(admission.reserve()).rejects.toMatchObject({ code: 'SCHEMA_CAPACITY_EXHAUSTED' });
first.release();
const next = await admission.reserve();
next.release();
});

it('refuses publication after a build exceeds the heap watermark', async () => {
let heap = 10;
const admission = new GraphileAdmission({ occupied: () => 0, evict: async () => false }, 2, () => heap, 100);
admission.configure({ buildReserveBytes: 10 });
const reservation = await admission.reserve();
heap = 101;
expect(() => reservation.assertPublishable()).toThrow('Schema capacity');
reservation.release();
});

it('does not recover from failed disposal when ownership counters reach zero', async () => {
const error = new Error('release failed');
const admission = new GraphileAdmission({ occupied: () => 0, evict: async () => false }, 2, () => 10, 100);
admission.configure({ buildReserveBytes: 10 });
const reservation = await admission.reserve();
admission.fail(error);
expect(() => reservation.assertPublishable()).toThrow(error);
await expect(admission.reserve()).rejects.toBe(error);
reservation.release();
expect(admission.stats).toMatchObject({ reserved: 0, failed: true });
admission.configure({ buildReserveBytes: 10 });
await expect(admission.reserve()).rejects.toBe(error);
});

it('combines process owners conservatively and rejects impossible or malformed budgets', () => {
const admission = new GraphileAdmission({ occupied: () => 0, evict: async () => false }, 10, () => 10, 100);
admission.configure({ max: 4, buildReserveBytes: 10 });
admission.configure({ max: 8, heapMaxBytes: 90, buildReserveBytes: 20 });
expect(admission.stats).toMatchObject({ max: 4, heapMaxBytes: 90, buildReserveBytes: 20 });
expect(() => admission.configure({ max: 0 })).toThrow();
expect(() => admission.configure({ buildReserveBytes: 90 })).toThrow();
});
});
72 changes: 72 additions & 0 deletions graphile/graphile-cache/src/__tests__/admitted-build.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { buildAdmittedGraphileInstance } from '../admitted-build';
import {
clearGraphileCache,
configureGraphileAdmission,
getCacheStats,
graphileCache,
type GraphileCacheEntry
} from '../graphile-cache';

const metadata = (key: string) => ({ cacheKey: key, serviceKey: 'service', databaseId: 'database-id', poolKey: 'physical-db' });
const entry = (key: string, release = jest.fn(async (): Promise<void> => undefined)): GraphileCacheEntry => ({
cacheKey: key,
createdAt: Date.now(),
pgl: { release },
serv: {},
handler: {},
httpServer: { listening: false }
} as unknown as GraphileCacheEntry);

beforeAll(() => configureGraphileAdmission({ max: 1, heapMaxBytes: 1024 * 1024 * 1024, buildReserveBytes: 0 }));
afterEach(async () => {
jest.restoreAllMocks();
await clearGraphileCache();
});

it('waits for old services to release before invoking the next factory', async () => {
let finishRelease!: () => void;
const released = new Promise<void>((resolve) => { finishRelease = resolve; });
await buildAdmittedGraphileInstance(metadata('old'), async () => entry('old', jest.fn(() => released)));
const create = jest.fn(async () => entry('new'));
const pending = buildAdmittedGraphileInstance(metadata('new'), create);
await new Promise((resolve) => setImmediate(resolve));
expect(create).not.toHaveBeenCalled();
expect(getCacheStats()).toMatchObject({ size: 0, disposing: 1 });
finishRelease();
const result = await pending;
expect(result).toMatchObject(metadata('new'));
expect(graphileCache.get('new')).toBe(result);
expect(getCacheStats().reserved).toBe(0);
});

it('disposes an oversized completed result instead of publishing it', async () => {
const release = jest.fn(async (): Promise<void> => undefined);
const memory = process.memoryUsage();
await expect(buildAdmittedGraphileInstance(metadata('oversized'), async () => {
jest.spyOn(process, 'memoryUsage').mockReturnValue({ ...memory, heapUsed: 2 * 1024 * 1024 * 1024 });
return entry('oversized', release);
})).rejects.toMatchObject({ code: 'SCHEMA_CAPACITY_EXHAUSTED' });
expect(release).toHaveBeenCalledTimes(1);
expect(graphileCache.has('oversized')).toBe(false);
expect(getCacheStats().reserved).toBe(0);
});

it('returns failed-factory capacity so a subsequent build can succeed', async () => {
const error = new Error('schema failed');
await expect(buildAdmittedGraphileInstance(metadata('failed'), async () => { throw error; })).rejects.toBe(error);
const result = await buildAdmittedGraphileInstance(metadata('retry'), async () => entry('retry'));
expect(graphileCache.get('retry')).toBe(result);
expect(getCacheStats().reserved).toBe(0);
});

it('refuses new builds after an eviction cannot release its resources', async () => {
const failure = new Error('service release failed');
await buildAdmittedGraphileInstance(metadata('failed-release'), async () =>
entry('failed-release', jest.fn(async () => { throw failure; })));
const create = jest.fn(async () => entry('replacement'));
await expect(buildAdmittedGraphileInstance(metadata('replacement'), create)).rejects.toBe(failure);
expect(create).not.toHaveBeenCalled();
expect(getCacheStats()).toMatchObject({ admissionFailed: true, reserved: 0 });
await expect(buildAdmittedGraphileInstance(metadata('later'), create)).rejects.toBe(failure);
await expect(clearGraphileCache()).rejects.toBe(failure);
});
Loading