diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index eda7905997..cbe9a3f38c 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -113,7 +113,7 @@ jobs: - batch: graphql packages: 'graphql/query graphql/codegen' - batch: graphile-unit - packages: 'graphile/graphile-plugin-utils graphile/graphile-realtime-subscriptions graphile/graphile-sql-expression-validator graphile/graphile-upload-plugin graphile/graphile-storage-registry' + packages: 'graphile/graphile-plugin-utils graphile/graphile-realtime-subscriptions graphile/graphile-sql-expression-validator graphile/graphile-upload-plugin graphile/graphile-storage-registry graphile/graphile-cache' - batch: agentic packages: 'agentic/protocol agentic/agentic-kit agentic/agent agentic/harness agentic/chat agentic/cli agentic/db-tools agentic/pi agentic/dsh agentic/react agentic/agentic-server agentic/anthropic agentic/openai agentic/ollama agentic/run-log agentic/metering agentic/agent-conversation agentic/pi-host agentic/run-log-client agentic/run-log-gate agentic/machine-protocol agentic/machine-runner agentic/agent-cli' - batch: pgpm-unit diff --git a/graphile/graphile-cache/src/__tests__/build-readiness.test.ts b/graphile/graphile-cache/src/__tests__/build-readiness.test.ts new file mode 100644 index 0000000000..a23a912b9e --- /dev/null +++ b/graphile/graphile-cache/src/__tests__/build-readiness.test.ts @@ -0,0 +1,178 @@ +import { awaitGraphileBuildReadiness } from '../utils/build-readiness'; + +const deferred = () => { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +}; + +// Give unhandled rejections a full event-loop turn to surface in Jest. +const nextTurn = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +describe('awaitGraphileBuildReadiness', () => { + it.each(['schema', 'ready'] as const)('waits when %s is the last prerequisite', async (last) => { + const schema = deferred(); + const attachment = deferred(); + const readiness = deferred(); + const ready = jest.fn(() => readiness.promise); + const release = jest.fn(); + let resolved = false; + const build = awaitGraphileBuildReadiness({ + schemaResult: schema.promise, + addTo: () => attachment.promise, + ready, + release, + }); + const observed = build.then(() => { resolved = true; }); + + await nextTurn(); + expect(ready).not.toHaveBeenCalled(); + attachment.resolve(); + (last === 'schema' ? readiness : schema).resolve(); + await nextTurn(); + expect(ready).toHaveBeenCalledTimes(1); + expect(resolved).toBe(false); + + (last === 'schema' ? schema : readiness).resolve(); + await expect(build).resolves.toBeUndefined(); + await observed; + expect(release).not.toHaveBeenCalled(); + }); + + it('accepts synchronous void operations', async () => { + await expect(awaitGraphileBuildReadiness({ + schemaResult: Promise.resolve(), + addTo: () => {}, + ready: () => {}, + release: () => {}, + })).resolves.toBeUndefined(); + }); + + it.each([ + ['addTo', 'throw', 'resolve'], + ['addTo', 'reject', 'reject'], + ['ready', 'throw', 'reject'], + ['ready', 'reject', 'resolve'], + ] as const)('waits for schema after %s %s (schema will %s)', async (stage, mode, schemaResult) => { + const schema = deferred(); + const cleanup = deferred(); + const failure = new Error(`${stage} failed`); + const fail = (): void | Promise => { + if (mode === 'throw') throw failure; + return Promise.reject(failure); + }; + const ready = jest.fn(stage === 'ready' ? fail : () => {}); + const release = jest.fn(() => cleanup.promise); + let rejected = false; + const observed = awaitGraphileBuildReadiness({ + schemaResult: schema.promise, + addTo: stage === 'addTo' ? fail : () => {}, + ready, + release, + }).catch((error: unknown) => { + rejected = true; + return error; + }); + + await nextTurn(); + expect(release).not.toHaveBeenCalled(); + if (stage === 'addTo') expect(ready).not.toHaveBeenCalled(); + if (schemaResult === 'resolve') schema.resolve(); + else schema.reject(new Error('schema also failed')); + await nextTurn(); + expect(release).toHaveBeenCalledTimes(1); + expect(rejected).toBe(false); + + cleanup.resolve(); + expect(await observed).toBe(failure); + }); + + it.each(['resolve', 'reject'] as const)('observes early schema failure while addTo will %s', async (attachmentResult) => { + const schema = deferred(); + const attachment = deferred(); + const schemaFailure = new Error('schema failed'); + const attachmentFailure = new Error('attachment failed'); + const release = jest.fn(); + const observed = awaitGraphileBuildReadiness({ + schemaResult: schema.promise, + addTo: () => attachment.promise, + ready: () => {}, + release, + }).catch((error: unknown) => error); + + schema.reject(schemaFailure); + await nextTurn(); + expect(release).not.toHaveBeenCalled(); + if (attachmentResult === 'resolve') attachment.resolve(); + else attachment.reject(attachmentFailure); + + expect(await observed).toBe(attachmentResult === 'resolve' ? schemaFailure : attachmentFailure); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('observes both schema and Grafserv rejections', async () => { + const schemaFailure = new Error('schema failed'); + const readyFailure = new Error('Grafserv failed'); + const release = jest.fn(); + await expect(awaitGraphileBuildReadiness({ + schemaResult: Promise.reject(schemaFailure), + addTo: () => {}, + ready: () => Promise.reject(readyFailure), + release, + })).rejects.toBe(readyFailure); + await nextTurn(); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('waits for Grafserv to settle before releasing a failed schema', async () => { + const ready = deferred(); + const failure = new Error('schema failed'); + const release = jest.fn(); + const observed = awaitGraphileBuildReadiness({ + schemaResult: Promise.reject(failure), + addTo: () => {}, + ready: () => ready.promise, + release, + }).catch((error: unknown) => error); + + await nextTurn(); + expect(release).not.toHaveBeenCalled(); + ready.resolve(); + expect(await observed).toBe(failure); + expect(release).toHaveBeenCalledTimes(1); + }); + + it.each(['throw', 'reject'] as const)('retains the original error when release will %s', async (mode) => { + const failure = new Error('schema failed'); + const cleanupFailure = new Error('release failed'); + const onReleaseError = jest.fn(); + await expect(awaitGraphileBuildReadiness({ + schemaResult: Promise.reject(failure), + addTo: () => {}, + ready: () => {}, + release: () => { + if (mode === 'throw') throw cleanupFailure; + return Promise.reject(cleanupFailure); + }, + onReleaseError, + })).rejects.toMatchObject({ errors: [failure, cleanupFailure], cause: failure }); + expect(onReleaseError).toHaveBeenCalledWith(cleanupFailure); + }); + + it('includes a reporting failure without replacing the original cause', async () => { + const failure = new Error('schema failed'); + const cleanupFailure = new Error('release failed'); + const reportingFailure = new Error('reporting failed'); + await expect(awaitGraphileBuildReadiness({ + schemaResult: Promise.reject(failure), + addTo: () => {}, + ready: () => {}, + release: () => { throw cleanupFailure; }, + onReleaseError: () => { throw reportingFailure; }, + })).rejects.toMatchObject({ errors: [failure, cleanupFailure, reportingFailure], cause: failure }); + }); +}); diff --git a/graphile/graphile-cache/src/__tests__/create-instance.test.ts b/graphile/graphile-cache/src/__tests__/create-instance.test.ts new file mode 100644 index 0000000000..4fc2384502 --- /dev/null +++ b/graphile/graphile-cache/src/__tests__/create-instance.test.ts @@ -0,0 +1,265 @@ +import { createGraphileInstance } from '../create-instance'; + +jest.mock('postgraphile', () => ({ + postgraphile: (...args: unknown[]) => mockPostgraphile(...args), +})); + +jest.mock('express', () => ({ + __esModule: true, + default: (...args: unknown[]) => mockExpress(...args), +})); + +jest.mock('node:http', () => ({ + createServer: (...args: unknown[]) => mockCreateServer(...args), +})); + +jest.mock('grafserv/express/v4', () => ({ grafserv: jest.fn() })); + +jest.mock('graphile-realtime-subscriptions', () => ({ + RealtimeManager: function (...args: unknown[]) { + return mockCreateRealtimeManager(...args); + }, +})); + +jest.mock('@pgpmjs/logger', () => ({ + Logger: function () { + return { + info: (...args: unknown[]) => mockInfo(...args), + warn: (...args: unknown[]) => mockWarn(...args), + error: (...args: unknown[]) => mockError(...args), + }; + }, +})); + +const mockPostgraphile = jest.fn(); +const mockExpress = jest.fn(); +const mockCreateServer = jest.fn(); +const mockCreateRealtimeManager = jest.fn(); +const mockInfo = jest.fn(); +const mockWarn = jest.fn(); +const mockError = jest.fn(); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const flushAsyncWork = () => new Promise((resolve) => setImmediate(resolve)); + +describe('createGraphileInstance', () => { + const preset = { grafserv: { graphqlPath: '/graphql' } }; + const cacheKey = 'readiness-test'; + const handler = jest.fn(); + const httpServer = { close: jest.fn() }; + const pgSubscriber = {}; + const pool = {}; + const releaseService = jest.fn(); + const schemaResult = { schema: {} }; + const manager = { start: jest.fn() }; + const serv = { + addTo: jest.fn(), + ready: jest.fn(), + }; + const pgl = { + createServ: jest.fn(), + getSchemaResult: jest.fn(), + getResolvedPreset: jest.fn(), + release: jest.fn(), + }; + + beforeEach(() => { + jest.resetAllMocks(); + mockPostgraphile.mockReturnValue(pgl); + mockExpress.mockReturnValue(handler); + mockCreateServer.mockReturnValue(httpServer); + mockCreateRealtimeManager.mockReturnValue(manager); + pgl.createServ.mockReturnValue(serv); + pgl.getSchemaResult.mockResolvedValue(schemaResult); + pgl.getResolvedPreset.mockReturnValue({ + pgServices: [{ pgSubscriber, adaptorSettings: { pool }, release: releaseService }], + }); + pgl.release.mockResolvedValue(undefined); + releaseService.mockResolvedValue(undefined); + serv.addTo.mockResolvedValue(undefined); + serv.ready.mockResolvedValue(undefined); + manager.start.mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it.each(['schema', 'HTTP'] as const)( + 'waits for schema, HTTP and Grafserv before realtime when %s completes first', + async (first) => { + const schema = deferred(); + const attachment = deferred(); + const ready = deferred(); + const started = deferred(); + pgl.getSchemaResult.mockReturnValue(schema.promise); + serv.addTo.mockReturnValue(attachment.promise); + serv.ready.mockReturnValue(ready.promise); + manager.start.mockReturnValue(started.promise); + + const settled = jest.fn(); + const instance = createGraphileInstance({ preset, cacheKey, enableRealtime: true }); + void instance.then(settled, settled); + await flushAsyncWork(); + + expect(mockPostgraphile).toHaveBeenCalledWith(preset); + expect(pgl.getSchemaResult).toHaveBeenCalledTimes(1); + expect(mockCreateServer).toHaveBeenCalledWith(handler); + expect(serv.addTo).toHaveBeenCalledWith(handler, httpServer); + expect(serv.ready).not.toHaveBeenCalled(); + + if (first === 'schema') { + schema.resolve(schemaResult); + } else { + attachment.resolve(); + } + await flushAsyncWork(); + expect(serv.ready).toHaveBeenCalledTimes(first === 'HTTP' ? 1 : 0); + expect(mockCreateRealtimeManager).not.toHaveBeenCalled(); + expect(settled).not.toHaveBeenCalled(); + + schema.resolve(schemaResult); + attachment.resolve(); + await flushAsyncWork(); + expect(serv.ready).toHaveBeenCalledTimes(1); + expect(mockCreateRealtimeManager).not.toHaveBeenCalled(); + expect(settled).not.toHaveBeenCalled(); + + ready.resolve(); + await flushAsyncWork(); + expect(mockCreateRealtimeManager).toHaveBeenCalledWith({ + pgSubscriber, + pool, + nodeId: `graphile-cache:${cacheKey}`, + schema: 'realtime_public', + }); + expect(manager.start).toHaveBeenCalledTimes(1); + expect(settled).not.toHaveBeenCalled(); + + started.resolve(); + await expect(instance).resolves.toEqual({ + pgl, + serv, + handler, + httpServer, + cacheKey, + createdAt: expect.any(Number), + realtimeManager: manager, + }); + expect(pgl.release).not.toHaveBeenCalled(); + expect(releaseService).not.toHaveBeenCalled(); + }, + ); + + it('accepts a synchronous schema result and void HTTP and Grafserv readiness results', async () => { + pgl.getSchemaResult.mockReturnValue(schemaResult); + serv.addTo.mockReturnValue(undefined); + serv.ready.mockReturnValue(undefined); + + await expect(createGraphileInstance({ preset, cacheKey })).resolves.toMatchObject({ + pgl, + serv, + handler, + httpServer, + cacheKey, + }); + expect(serv.ready).toHaveBeenCalledTimes(1); + expect(mockCreateRealtimeManager).not.toHaveBeenCalled(); + expect(pgl.release).not.toHaveBeenCalled(); + expect(releaseService).not.toHaveBeenCalled(); + }); + + it('waits for schema settlement and cleanup while preserving the original HTTP failure', async () => { + const schema = deferred(); + const attachment = deferred(); + const released = deferred(); + const attachmentError = new Error('HTTP attachment failed'); + const schemaError = new Error('schema failed after HTTP attachment'); + pgl.getSchemaResult.mockReturnValue(schema.promise); + serv.addTo.mockReturnValue(attachment.promise); + pgl.release.mockReturnValue(released.promise); + + const settled = jest.fn(); + const instance = createGraphileInstance({ preset, cacheKey, enableRealtime: true }); + void instance.then(settled, settled); + const rejection = expect(instance).rejects.toBe(attachmentError); + await flushAsyncWork(); + attachment.reject(attachmentError); + await flushAsyncWork(); + expect(pgl.release).not.toHaveBeenCalled(); + expect(releaseService).not.toHaveBeenCalled(); + expect(settled).not.toHaveBeenCalled(); + + schema.reject(schemaError); + await flushAsyncWork(); + expect(pgl.release).toHaveBeenCalledTimes(1); + expect(releaseService).not.toHaveBeenCalled(); + expect(settled).not.toHaveBeenCalled(); + + released.resolve(); + await rejection; + expect(releaseService).toHaveBeenCalledTimes(1); + expect(mockError).not.toHaveBeenCalled(); + expect(serv.ready).not.toHaveBeenCalled(); + expect(mockCreateRealtimeManager).not.toHaveBeenCalled(); + }); + + it('reports cleanup failure with the original build error as its cause', async () => { + const buildError = new Error('Grafserv readiness failed'); + const releaseError = new Error('PostGraphile release failed'); + serv.ready.mockRejectedValue(buildError); + pgl.release.mockRejectedValue(releaseError); + + await expect(createGraphileInstance({ preset, cacheKey })).rejects.toMatchObject({ + cause: buildError, + errors: [buildError, releaseError], + }); + expect(mockError).toHaveBeenCalledWith( + `Failed to release PostGraphile[${cacheKey}] after build failure:`, + releaseError, + ); + expect(releaseService).toHaveBeenCalledTimes(1); + }); + + it('still returns a ready instance when realtime startup fails', async () => { + const realtimeError = new Error('realtime unavailable'); + manager.start.mockRejectedValue(realtimeError); + + const entry = await createGraphileInstance({ preset, cacheKey, enableRealtime: true }); + + expect(entry.pgl).toBe(pgl); + expect(entry.realtimeManager).toBeUndefined(); + expect(mockError).toHaveBeenCalledWith( + `Failed to start RealtimeManager for PostGraphile[${cacheKey}]:`, + realtimeError, + ); + expect(pgl.release).not.toHaveBeenCalled(); + expect(releaseService).not.toHaveBeenCalled(); + }); + + it.each([ + { pgService: { adaptorSettings: { pool } }, missing: 'pgSubscriber' }, + { pgService: { pgSubscriber }, missing: 'pool in pgService' }, + ])('still skips realtime when $missing is absent', async ({ pgService, missing }) => { + pgl.getResolvedPreset.mockReturnValue({ pgServices: [pgService] }); + + const entry = await createGraphileInstance({ preset, cacheKey, enableRealtime: true }); + + expect(entry.pgl).toBe(pgl); + expect(entry.realtimeManager).toBeUndefined(); + expect(mockCreateRealtimeManager).not.toHaveBeenCalled(); + expect(mockWarn).toHaveBeenCalledWith( + `PostGraphile[${cacheKey}] has no ${missing} — RealtimeManager will not be started`, + ); + expect(pgl.release).not.toHaveBeenCalled(); + }); +}); diff --git a/graphile/graphile-cache/src/__tests__/release-failed-build.test.ts b/graphile/graphile-cache/src/__tests__/release-failed-build.test.ts new file mode 100644 index 0000000000..4dfe4e1488 --- /dev/null +++ b/graphile/graphile-cache/src/__tests__/release-failed-build.test.ts @@ -0,0 +1,112 @@ +import { releaseFailedBuild } from '../utils/release-failed-build'; + +type FailedBuild = Parameters[0]; + +const makeService = ( + release?: () => void | PromiseLike +) => ({ release }); + +const makeInstance = ( + services?: readonly ReturnType[], + release: FailedBuild['release'] = jest.fn() +): FailedBuild => ({ + release, + getResolvedPreset: () => ({ pgServices: services }), +}); + +describe('releaseFailedBuild', () => { + it('waits for PostGraphile, then releases services in reverse order', async () => { + const events: string[] = []; + let finishPostGraphile!: () => void; + const postGraphileReleased = new Promise((resolve) => { + finishPostGraphile = resolve; + }); + let finishSecond!: () => void; + const secondReleased = new Promise((resolve) => { + finishSecond = resolve; + }); + const first = makeService(() => { events.push('first'); }); + const second = makeService(async () => { + events.push('second'); + await secondReleased; + }); + const release = jest.fn(async () => { + events.push('postgraphile'); + await postGraphileReleased; + }); + + const result = releaseFailedBuild(makeInstance([first, second], release)); + await Promise.resolve(); + expect(events).toEqual(['postgraphile']); + + finishPostGraphile(); + await new Promise((resolve) => setImmediate(resolve)); + expect(events).toEqual(['postgraphile', 'second']); + + finishSecond(); + await expect(result).resolves.toBeUndefined(); + expect(events).toEqual(['postgraphile', 'second', 'first']); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('snapshots and deduplicates services before releasing PostGraphile', async () => { + const releaseService = jest.fn(); + const service = makeService(releaseService); + const services = [service, makeService(), service]; + const release = jest.fn(() => { services.length = 0; }); + + await releaseFailedBuild(makeInstance(services, release)); + + expect(releaseService).toHaveBeenCalledTimes(1); + }); + + it('releases PostGraphile when the preset has no services', async () => { + const release = jest.fn(); + + await expect(releaseFailedBuild(makeInstance(undefined, release))).resolves.toBeUndefined(); + + expect(release).toHaveBeenCalledTimes(1); + }); + + it('preserves a single release error and still attempts remaining services', async () => { + const error = new Error('service release failed'); + const releaseFirst = jest.fn(); + const first = makeService(releaseFirst); + const second = makeService(() => { throw error; }); + + await expect(releaseFailedBuild(makeInstance([first, second]))).rejects.toBe(error); + + expect(releaseFirst).toHaveBeenCalledTimes(1); + }); + + it('collects synchronous and asynchronous errors without skipping any releases', async () => { + const postGraphileError = new Error('PostGraphile release failed'); + const firstError = new Error('first service release failed'); + const secondError = new Error('second service release failed'); + const events: string[] = []; + const first = makeService(() => { + events.push('first'); + throw firstError; + }); + const second = makeService(async () => { + events.push('second'); + throw secondError; + }); + const release = () => { + events.push('postgraphile'); + throw postGraphileError; + }; + + const error = await releaseFailedBuild(makeInstance([first, second], release)).catch( + (caught: unknown) => caught + ); + + expect(events).toEqual(['postgraphile', 'second', 'first']); + expect(error).toBeInstanceOf(AggregateError); + if (!(error instanceof AggregateError)) throw new Error('Expected aggregate cleanup error'); + expect(error.errors).toHaveLength(3); + expect(error.errors[0]).toBe(postGraphileError); + expect(error.errors[1]).toBe(secondError); + expect(error.errors[2]).toBe(firstError); + }); +}); diff --git a/graphile/graphile-cache/src/create-instance.ts b/graphile/graphile-cache/src/create-instance.ts index a522701720..1aba497480 100644 --- a/graphile/graphile-cache/src/create-instance.ts +++ b/graphile/graphile-cache/src/create-instance.ts @@ -7,6 +7,8 @@ import { RealtimeManager } from 'graphile-realtime-subscriptions'; import { postgraphile } from 'postgraphile'; import type { GraphileCacheEntry } from './graphile-cache'; +import { awaitGraphileBuildReadiness } from './utils/build-readiness'; +import { releaseFailedBuild } from './utils/release-failed-build'; const log = new Logger('graphile-cache:create'); @@ -30,6 +32,9 @@ interface GraphileInstanceOptions { * * Callers are responsible for building the `GraphileConfig.Preset` (including * pgServices, grafserv options, grafast context, etc.) before passing it here. + * On a readiness failure, this factory releases PostGraphile and the preset's + * services after schema construction settles; externally supplied pools stay + * owned by the caller. * * When `enableRealtime` is true, a RealtimeManager is created that bridges * cursor-tracked events from `drain_changes()` into the PostGraphile @@ -47,8 +52,16 @@ export const createGraphileInstance = async ( const handler = express(); const httpServer = createServer(handler); - await serv.addTo(handler, httpServer); - await serv.ready(); + await awaitGraphileBuildReadiness({ + // The upstream API returns a schema result object, possibly asynchronously. + schemaResult: Promise.resolve(pgl.getSchemaResult()).then(() => {}), + addTo: () => serv.addTo(handler, httpServer), + ready: () => serv.ready(), + release: () => releaseFailedBuild(pgl), + onReleaseError: (releaseError) => { + log.error(`Failed to release PostGraphile[${cacheKey}] after build failure:`, releaseError); + }, + }); const entry: GraphileCacheEntry = { pgl, diff --git a/graphile/graphile-cache/src/index.ts b/graphile/graphile-cache/src/index.ts index 9a845fafe3..fd06d960c0 100644 --- a/graphile/graphile-cache/src/index.ts +++ b/graphile/graphile-cache/src/index.ts @@ -26,4 +26,4 @@ export { export { createGraphileInstance } from './create-instance'; // Generic module config cache for plugin lookups -export { ModuleConfigCache, ModuleConfigCacheOptions } from './module-config-cache'; +export { ModuleConfigCache, ModuleConfigCacheOptions } from './utils/module-config-cache'; diff --git a/graphile/graphile-cache/src/utils/build-readiness.ts b/graphile/graphile-cache/src/utils/build-readiness.ts new file mode 100644 index 0000000000..347d8f34e1 --- /dev/null +++ b/graphile/graphile-cache/src/utils/build-readiness.ts @@ -0,0 +1,39 @@ +export interface GraphileBuildReadiness { + schemaResult: Promise; + addTo(): void | PromiseLike; + ready(): void | PromiseLike; + release(): void | PromiseLike; + onReleaseError?(error: unknown): void; +} + +/** Wait for schema construction, HTTP attachment and Grafserv readiness. */ +export const awaitGraphileBuildReadiness = async ( + build: GraphileBuildReadiness +): Promise => { + // Observe schema rejection immediately, even while addTo is still pending. + // Keeping it as an outcome also lets failure cleanup wait for schema work. + const schemaOutcome = 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) { + // Schema gathering may still be using the services we are about to release. + await schemaOutcome; + try { + await build.release(); + } catch (releaseError) { + const failures = [error, releaseError]; + try { + build.onReleaseError?.(releaseError); + } catch (observerError) { + failures.push(observerError); + } + throw new AggregateError(failures, 'Graphile build and cleanup failed', { cause: error }); + } + throw error; + } +}; diff --git a/graphile/graphile-cache/src/module-config-cache.ts b/graphile/graphile-cache/src/utils/module-config-cache.ts similarity index 100% rename from graphile/graphile-cache/src/module-config-cache.ts rename to graphile/graphile-cache/src/utils/module-config-cache.ts diff --git a/graphile/graphile-cache/src/utils/release-failed-build.ts b/graphile/graphile-cache/src/utils/release-failed-build.ts new file mode 100644 index 0000000000..1a9b64c6fb --- /dev/null +++ b/graphile/graphile-cache/src/utils/release-failed-build.ts @@ -0,0 +1,38 @@ +interface ReleasableService { + release?(): void | PromiseLike; +} + +interface FailedBuild { + release(): void | PromiseLike; + getResolvedPreset(): { pgServices?: readonly ReleasableService[] }; +} + +/** + * Release a failed build after its schema work has settled. PostGraphile releases + * Grafserv and schema watching; each service releases only its own resources. + */ +export const releaseFailedBuild = async ( + pgl: FailedBuild +): Promise => { + const services = [...new Set(pgl.getResolvedPreset().pgServices ?? [])]; + const errors: unknown[] = []; + + try { + await pgl.release(); + } catch (error) { + errors.push(error); + } + + for (const service of services.reverse()) { + try { + await service.release?.(); + } catch (error) { + errors.push(error); + } + } + + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) { + throw new AggregateError(errors, 'Failed to release Graphile build resources'); + } +};