Skip to content
Draft
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.toBe(failure);
expect(onReleaseError).toHaveBeenCalledWith(cleanupFailure);
});
});
175 changes: 175 additions & 0 deletions graphile/graphile-cache/src/__tests__/disposal-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
jest.mock('@pgpmjs/logger', () => ({
Logger: jest.fn(() => ({
debug: jest.fn(),
error: jest.fn(),
})),
}));

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

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

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

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

const makeEntry = (
cacheKey: string,
release = jest.fn().mockResolvedValue(undefined),
releasePresetServices = jest.fn().mockResolvedValue(undefined)
): GraphileCacheEntry =>
({
pgl: { release },
serv: {},
handler: {},
httpServer: { listening: false },
cacheKey,
createdAt: Date.now(),
releasePresetServices,
}) as unknown as GraphileCacheEntry;

describe('Graphile cache disposal lifecycle', () => {
afterEach(async () => {
await clearGraphileCache();
});

it('coalesces concurrent disposal of one exact entry', async () => {
const release = jest.fn().mockResolvedValue(undefined);
const releasePresetServices = jest.fn().mockResolvedValue(undefined);
const entry = makeEntry('same-entry', release, releasePresetServices);

const first = disposeUncachedEntry(entry);
const second = disposeUncachedEntry(entry);

expect(second).toBe(first);
await Promise.all([first, second]);
expect(release).toHaveBeenCalledTimes(1);
expect(releasePresetServices).toHaveBeenCalledTimes(1);
});

it('disposes distinct generations that reuse the same cache key', async () => {
const firstRelease = jest.fn().mockResolvedValue(undefined);
const secondRelease = jest.fn().mockResolvedValue(undefined);
const first = makeEntry('shared-key', firstRelease);
const second = makeEntry('shared-key', secondRelease);

await Promise.all([
disposeUncachedEntry(first),
disposeUncachedEntry(second),
]);

expect(firstRelease).toHaveBeenCalledTimes(1);
expect(secondRelease).toHaveBeenCalledTimes(1);
});

it('continues cleanup and exposes the first disposal failure', async () => {
const failure = new Error('realtime stop failed');
const release = jest.fn().mockResolvedValue(undefined);
const releasePresetServices = jest.fn().mockResolvedValue(undefined);
const entry = makeEntry('failed-cleanup', release, releasePresetServices);
entry.realtimeManager = { stop: jest.fn().mockRejectedValue(failure) };

await expect(disposeUncachedEntry(entry)).rejects.toBe(failure);
expect(release).toHaveBeenCalledTimes(1);
expect(releasePresetServices).toHaveBeenCalledTimes(1);
});

it('lets callers await an eviction through the exact entry', async () => {
const release = deferred<void>();
const releasePresetServices = jest.fn().mockResolvedValue(undefined);
const entry = makeEntry(
'evicted-entry',
jest.fn(() => release.promise),
releasePresetServices
);
graphileCache.set(entry.cacheKey, entry);
graphileCache.delete(entry.cacheKey);

let disposed = false;
const waiting = waitForEntryDisposal(entry).then(() => {
disposed = true;
});
await flushPromises();
expect(disposed).toBe(false);

release.resolve(undefined);
await waiting;
expect(disposed).toBe(true);
expect(releasePresetServices).toHaveBeenCalledTimes(1);
});

it('does not resolve a cache clear before resident disposal completes', async () => {
const release = deferred<void>();
const entry = makeEntry(
'clear-entry',
jest.fn(() => release.promise)
);
graphileCache.set(entry.cacheKey, entry);

let cleared = false;
const clearing = clearGraphileCache().then(() => {
cleared = true;
});
await flushPromises();
expect(graphileCache.size).toBe(0);
expect(cleared).toBe(false);

release.resolve(undefined);
await clearing;
expect(cleared).toBe(true);
});

it('does not complete entry disposal before preset services are released', async () => {
const releasePresetServices = deferred<void>();
const entry = makeEntry(
'delayed-preset-service',
jest.fn().mockResolvedValue(undefined),
jest.fn(() => releasePresetServices.promise)
);
graphileCache.set(entry.cacheKey, entry);

graphileCache.delete(entry.cacheKey);
const disposal = waitForEntryDisposal(entry);
let disposed = false;
void disposal.then(() => {
disposed = true;
});

await flushPromises();
expect(disposed).toBe(false);

releasePresetServices.resolve(undefined);
await disposal;
expect(disposed).toBe(true);
});

it('disposes a new generation after an older same-key generation fails', async () => {
const firstFailure = new Error('first generation release failed');
const firstRelease = jest.fn().mockRejectedValue(firstFailure);
const secondRelease = jest.fn().mockResolvedValue(undefined);
const first = makeEntry('retry-by-generation', firstRelease);
const second = makeEntry('retry-by-generation', secondRelease);

await expect(disposeUncachedEntry(first)).rejects.toBe(firstFailure);
await expect(disposeUncachedEntry(second)).resolves.toBeUndefined();

expect(firstRelease).toHaveBeenCalledTimes(1);
expect(secondRelease).toHaveBeenCalledTimes(1);
});
});
Loading
Loading