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
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
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
1 change: 1 addition & 0 deletions graphile/graphile-cache/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
164 changes: 164 additions & 0 deletions graphile/graphile-cache/src/__tests__/build-readiness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { awaitGraphileBuildReadiness } from '../build-readiness';

interface Deferred<T> {
promise: Promise<T>;
resolve(value: T): void;
reject(error: Error): void;
}

const deferred = <T>(): Deferred<T> => {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};

const flushPromises = (): Promise<void> =>
new Promise((resolve) => setImmediate(resolve));

describe('awaitGraphileBuildReadiness', () => {
it('does not resolve before schema gathering and Grafserv are ready', async () => {
const schemaResult = deferred<unknown>();
const ready = deferred<unknown>();
const release = jest.fn().mockResolvedValue(undefined);
let resolved = false;
const buildPromise = awaitGraphileBuildReadiness({
schemaResult: schemaResult.promise,
addTo: jest.fn().mockResolvedValue(undefined),
ready: () => ready.promise,
release,
}).then(() => {
resolved = true;
});

schemaResult.resolve({});
await flushPromises();
expect(resolved).toBe(false);

ready.resolve(undefined);
await buildPromise;
expect(release).not.toHaveBeenCalled();
});

it('does not start readiness checks before the adapter is attached', async () => {
const addTo = deferred<unknown>();
const ready = jest.fn().mockResolvedValue(undefined);
const buildPromise = awaitGraphileBuildReadiness({
schemaResult: Promise.resolve({}),
addTo: () => addTo.promise,
ready,
release: jest.fn().mockResolvedValue(undefined),
});

await flushPromises();
expect(ready).not.toHaveBeenCalled();

addTo.resolve(undefined);
await buildPromise;
expect(ready).toHaveBeenCalledTimes(1);
});

it('waits for schema construction before releasing after adapter attachment fails', async () => {
const schemaResult = deferred<unknown>();
const attachmentFailure = new Error('adapter attachment failed');
const release = jest.fn().mockResolvedValue(undefined);
const buildPromise = awaitGraphileBuildReadiness({
schemaResult: schemaResult.promise,
addTo: jest.fn().mockRejectedValue(attachmentFailure),
ready: jest.fn().mockResolvedValue(undefined),
release,
});

await flushPromises();
expect(release).not.toHaveBeenCalled();

schemaResult.resolve({});
await expect(buildPromise).rejects.toBe(attachmentFailure);
expect(release).toHaveBeenCalledTimes(1);
});

it('waits for schema construction before releasing after readiness fails', async () => {
const schemaResult = deferred<unknown>();
const readinessFailure = new Error('grafserv readiness failed');
const release = jest.fn().mockResolvedValue(undefined);
const buildPromise = awaitGraphileBuildReadiness({
schemaResult: schemaResult.promise,
addTo: jest.fn().mockResolvedValue(undefined),
ready: jest.fn().mockRejectedValue(readinessFailure),
release,
});

await flushPromises();
expect(release).not.toHaveBeenCalled();

schemaResult.resolve({});
await expect(buildPromise).rejects.toBe(readinessFailure);
expect(release).toHaveBeenCalledTimes(1);
});

it('observes schema failure while adapter attachment is pending', async () => {
const schemaResult = deferred<unknown>();
const addTo = deferred<unknown>();
const release = jest.fn().mockResolvedValue(undefined);
const failure = new Error('schema build failed early');
const buildPromise = awaitGraphileBuildReadiness({
schemaResult: schemaResult.promise,
addTo: () => addTo.promise,
ready: jest.fn().mockResolvedValue(undefined),
release,
});

schemaResult.reject(failure);
await flushPromises();
expect(release).not.toHaveBeenCalled();

addTo.resolve(undefined);
await expect(buildPromise).rejects.toBe(failure);
expect(release).toHaveBeenCalledTimes(1);
});

it('awaits failed-generation release before rejecting', async () => {
const schemaResult = deferred<unknown>();
const release = deferred<void>();
const releaseFn = jest.fn(() => release.promise);
const failure = new Error('schema build failed');
let rejected = false;
const buildPromise = awaitGraphileBuildReadiness({
schemaResult: schemaResult.promise,
addTo: jest.fn().mockResolvedValue(undefined),
ready: jest.fn().mockResolvedValue(undefined),
release: releaseFn,
}).catch((error) => {
rejected = true;
throw error;
});

schemaResult.reject(failure);
await flushPromises();
expect(releaseFn).toHaveBeenCalledTimes(1);
expect(rejected).toBe(false);

release.resolve(undefined);
await expect(buildPromise).rejects.toBe(failure);
});

it('preserves the build failure when cleanup also fails', async () => {
const failure = new Error('schema build failed');
const cleanupFailure = new Error('release failed');
const onReleaseError = jest.fn();

await expect(
awaitGraphileBuildReadiness({
schemaResult: Promise.reject(failure),
addTo: jest.fn().mockResolvedValue(undefined),
ready: jest.fn().mockResolvedValue(undefined),
release: jest.fn().mockRejectedValue(cleanupFailure),
onReleaseError,
})
).rejects.toMatchObject({ errors: [failure, cleanupFailure], cause: failure });
expect(onReleaseError).toHaveBeenCalledWith(cleanupFailure);
});
});
90 changes: 90 additions & 0 deletions graphile/graphile-cache/src/__tests__/disposal-drain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { pgCache } from 'pg-cache';

import {
clearGraphileCache,
closeAllCaches,
disposeUncachedEntry,
graphileCache,
waitForActiveDisposals,
type GraphileCacheEntry
} from '../graphile-cache';

const entry = (key: string, release: () => Promise<void>): GraphileCacheEntry => ({
cacheKey: key, createdAt: Date.now(), pgl: { release },
serv: {}, handler: {}, httpServer: { listening: false }
} as unknown as GraphileCacheEntry);

afterEach(async () => {
jest.restoreAllMocks();
await clearGraphileCache();
});

it('reports already-settled failures to concurrent drains without repeating acknowledged failures', async () => {
const failure = new Error('release failed');
const failed = entry('failed', async () => { throw failure; });
await expect(disposeUncachedEntry(failed)).rejects.toBe(failure);
const drains = [waitForActiveDisposals(), waitForActiveDisposals()];
await Promise.all(drains.map(drain => expect(drain).rejects.toBe(failure)));
await expect(waitForActiveDisposals()).resolves.toBeUndefined();
await expect(disposeUncachedEntry(failed)).rejects.toBe(failure);
});

it('reports multiple generations in scheduling order and attempts every resource', async () => {
const stopFailure = new Error('stop failed');
const releaseFailure = new Error('release failed');
const laterFailure = new Error('later failed');
const first = entry('first', async () => { throw releaseFailure; });
first.realtimeManager = { stop: async () => { throw stopFailure; } };
first.releasePresetServices = jest.fn().mockResolvedValue(undefined);
const firstDisposal = disposeUncachedEntry(first);
const later = disposeUncachedEntry(entry('later', async () => { throw laterFailure; }));
await expect(firstDisposal).rejects.toMatchObject({ errors: [stopFailure, releaseFailure] });
await expect(later).rejects.toBe(laterFailure);
await expect(waitForActiveDisposals()).rejects.toMatchObject({
errors: [expect.objectContaining({ errors: [stopFailure, releaseFailure] }), laterFailure]
});
expect(first.releasePresetServices).toHaveBeenCalledTimes(1);
});

it('leaves work scheduled after the drain boundary for the next caller', async () => {
let finishFirst!: () => void;
let finishLater!: () => void;
const first = disposeUncachedEntry(entry('first', () => new Promise(resolve => { finishFirst = resolve; })));
const drain = waitForActiveDisposals();
const later = disposeUncachedEntry(entry('later', () => new Promise(resolve => { finishLater = resolve; })));
finishFirst();
await drain;
await first;
let laterDrained = false;
const next = waitForActiveDisposals().then(() => { laterDrained = true; });
await new Promise(resolve => setImmediate(resolve));
expect(laterDrained).toBe(false);
finishLater();
await Promise.all([next, later]);
});

it('still closes pools when cache cleanup fails', async () => {
const failure = new Error('generation failed');
const poolFailure = new Error('pool failed');
graphileCache.set('failed', entry('failed', async () => { throw failure; }));
const close = jest.spyOn(pgCache, 'close').mockRejectedValue(poolFailure);
await expect(closeAllCaches()).rejects.toMatchObject({ errors: [failure, poolFailure] });
expect(close).toHaveBeenCalledTimes(1);
});

it('includes synchronous reentrant disposal scheduled while clearing residents', async () => {
const failure = new Error('nested release failed');
const nested = entry('nested', async () => { throw failure; });
graphileCache.set('outer', entry('outer', () => disposeUncachedEntry(nested)));
await expect(clearGraphileCache()).rejects.toMatchObject({ errors: [failure, failure] });
});

it('preserves a close callback error and still releases the remaining resources', async () => {
const failure = new Error('server close failed');
const release = jest.fn().mockResolvedValue(undefined);
const resident = entry('server', release);
resident.httpServer = { listening: true, close: (callback: (error: Error) => void) => callback(failure) } as any;
await expect(disposeUncachedEntry(resident)).rejects.toBe(failure);
expect(release).toHaveBeenCalledTimes(1);
await expect(waitForActiveDisposals()).rejects.toBe(failure);
});
Loading
Loading