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
126 changes: 126 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,126 @@
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('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);
});
});
137 changes: 137 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,137 @@
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);
});
});
50 changes: 50 additions & 0 deletions graphile/graphile-cache/src/__tests__/preset-services.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { createPresetServicesReleaser } from '../preset-services';

describe('preset service ownership', () => {
it('releases unique services in reverse order exactly once', async () => {
const events: string[] = [];
const first = {
release: jest.fn(async () => {
events.push('first');
}),
};
const second = {
release: jest.fn(async () => {
events.push('second');
}),
};
const release = createPresetServicesReleaser({
pgServices: [first, second, first],
});

const releases = [release(), release(), release()];
expect(releases[0]).toBe(releases[1]);
expect(releases[1]).toBe(releases[2]);
await Promise.all(releases);

expect(events).toEqual(['second', 'first']);
expect(first.release).toHaveBeenCalledTimes(1);
expect(second.release).toHaveBeenCalledTimes(1);
});

it('continues releasing services and preserves the first error', async () => {
const firstFailure = new Error('second failed');
const first = { release: jest.fn().mockResolvedValue(undefined) };
const second = { release: jest.fn().mockRejectedValue(firstFailure) };
const release = createPresetServicesReleaser({
pgServices: [first, second],
});

await expect(release()).rejects.toBe(firstFailure);
expect(first.release).toHaveBeenCalledTimes(1);
expect(second.release).toHaveBeenCalledTimes(1);
});

it('is a safe idempotent no-op when the preset has no services', async () => {
const release = createPresetServicesReleaser({});

const first = release();
expect(release()).toBe(first);
await first;
});
});
32 changes: 32 additions & 0 deletions graphile/graphile-cache/src/build-readiness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export interface GraphileBuildReadiness {
schemaResult: PromiseLike<unknown> | unknown;
addTo(): PromiseLike<unknown> | unknown;
ready(): PromiseLike<unknown> | unknown;
release(): PromiseLike<unknown> | unknown;
onReleaseError?(error: unknown): void;
}

/**
* Resolve only after schema gathering and the HTTP adapter are ready. A failed
* generation reaches its release terminal state before the failure escapes.
*/
export const awaitGraphileBuildReadiness = async (
build: GraphileBuildReadiness
): Promise<void> => {
const schemaOutcome = Promise.resolve(build.schemaResult).then(
() => ({ ready: true as const }),
(error: unknown) => ({ ready: false as const, error })
);
try {
await build.addTo();
const [schema] = await Promise.all([schemaOutcome, build.ready()]);
if ('error' in schema) throw schema.error;
} catch (error) {
try {
await build.release();
} catch (releaseError) {
build.onReleaseError?.(releaseError);
}
throw error;
}
};
Loading