diff --git a/.github/workflows/memtest.yml b/.github/workflows/memtest.yml index 212d45810..20bce626c 100644 --- a/.github/workflows/memtest.yml +++ b/.github/workflows/memtest.yml @@ -26,6 +26,7 @@ jobs: - opentelemetry - programmatic-batching - logger + - execution-cancellation e2e_runner: - node # - bun TODO: get memory snaps and heap sampling for bun. is it even necessary? diff --git a/e2e/execution-cancellation/execution-cancellation.memtest.ts b/e2e/execution-cancellation/execution-cancellation.memtest.ts new file mode 100644 index 000000000..180d80530 --- /dev/null +++ b/e2e/execution-cancellation/execution-cancellation.memtest.ts @@ -0,0 +1,56 @@ +import path from 'path'; +import { createExampleSetup, createTenv } from '@internal/e2e'; +import { memtest } from '@internal/perf/memtest'; +import { spawn, waitForPort } from '@internal/proc'; +import { getAvailablePort } from '@internal/testing'; +import { describe } from 'vitest'; + +const cwd = __dirname; + +const { gateway } = createTenv(cwd); +const { supergraph, query } = createExampleSetup(cwd); + +describe('Hive Gateway', () => { + memtest( + { + cwd, + query, + }, + async () => + gateway({ + supergraph: await supergraph(), + }), + ); +}); + +describe('Yoga', () => { + memtest( + { + cwd, + query: '{hello}', + }, + async () => { + const port = await getAvailablePort(); + const [proc] = await spawn( + { cwd, env: { PORT: port } }, + 'node', + '--inspect-port=0', // necessary for perf inspector + '--import', + 'tsx', + path.join(cwd, 'yoga-server.ts'), + ); + + await waitForPort({ + port, + protocol: 'http', + signal: new AbortController().signal, + }); + + return { + ...proc, + port, + protocol: 'http', + }; + }, + ); +}); diff --git a/e2e/execution-cancellation/gateway.config.ts b/e2e/execution-cancellation/gateway.config.ts new file mode 100644 index 000000000..7d606b129 --- /dev/null +++ b/e2e/execution-cancellation/gateway.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from '@graphql-hive/gateway'; + +export const gatewayConfig = defineConfig({ + executionCancellation: true, +}); diff --git a/e2e/execution-cancellation/package.json b/e2e/execution-cancellation/package.json new file mode 100644 index 000000000..0cdcba9b6 --- /dev/null +++ b/e2e/execution-cancellation/package.json @@ -0,0 +1,4 @@ +{ + "name": "@e2e/execution-cancellation", + "private": true +} diff --git a/e2e/execution-cancellation/yoga-server.ts b/e2e/execution-cancellation/yoga-server.ts new file mode 100644 index 000000000..34a60e0e6 --- /dev/null +++ b/e2e/execution-cancellation/yoga-server.ts @@ -0,0 +1,33 @@ +import { createServer } from 'node:http'; +import { + createSchema, + createYoga, + useExecutionCancellation, +} from 'graphql-yoga'; + +export const schema = createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => 'world', + }, + }, +}); + +// Create a Yoga instance with a GraphQL schema. +const yoga = createYoga({ + schema, + plugins: [useExecutionCancellation()], +}); + +// Pass it into a server to hook into request handlers. +const server = createServer(yoga); + +// Start the server and you're done! +server.listen(parseInt(process.env['PORT']!), () => { + console.info('Server is running on http://localhost:4000/graphql'); +}); diff --git a/internal/perf/src/graphql-loadtest-script.ts b/internal/perf/src/graphql-loadtest-script.ts index 46114c935..93e8f5d07 100644 --- a/internal/perf/src/graphql-loadtest-script.ts +++ b/internal/perf/src/graphql-loadtest-script.ts @@ -13,7 +13,9 @@ export default function () { return test.abort('Environment variable "QUERY" not provided'); } - const res = http.post(url, { query }); + const res = http.post(url, JSON.stringify({ query }), { + headers: { 'content-type': 'application/json' }, + }); if (__ENV['ALLOW_FAILING_REQUESTS']) { check(res, { diff --git a/internal/perf/src/loadtest.ts b/internal/perf/src/loadtest.ts index 1fe87a7bb..ad94d7f7c 100644 --- a/internal/perf/src/loadtest.ts +++ b/internal/perf/src/loadtest.ts @@ -9,6 +9,11 @@ import { connectInspector, Inspector } from './inspector'; export interface LoadtestOptions extends ProcOptions { cwd: string; + /** + * The ID of the loadtest, when running with test, prefer using the task id. + * @default Math.random().toString(36).slice(2, 6) + */ + id?: string; /** @default 100 */ vus?: number; /** Idling duration before loadtest in milliseconds. */ @@ -139,7 +144,7 @@ export async function loadtest(opts: LoadtestOptions): Promise<{ // we create a random id to make sure the heapsnapshot files are unique and easily distinguishable in the filesystem // when running multiple loadtests in parallel. see e2e/opentelemetry memtest as an example - const id = Math.random().toString(36).slice(2, 6); + const id = opts.id || Math.random().toString(36).slice(2, 6); // make sure the endpoint works before starting the loadtests // the request here matches the request done in loadtest-script.ts or http-loadtest-script.ts diff --git a/internal/perf/src/memtest.ts b/internal/perf/src/memtest.ts index 39ef5caf9..edb4705ef 100644 --- a/internal/perf/src/memtest.ts +++ b/internal/perf/src/memtest.ts @@ -16,7 +16,8 @@ const __project = path.resolve(__dirname, '..', '..', '..'); const supportedFlags = [ 'short' as const, - 'cleanheapsnaps' as const, + 'clean' as const, + 'keepheapsnaps' as const, 'noheapsnaps' as const, 'moreruns' as const, 'chart' as const, @@ -28,7 +29,8 @@ const supportedFlags = [ * * {@link supportedFlags Supported flags} are: * - `short` Runs the loadtest for `30s` and the calmdown for `10s` instead of the defaults. - * - `cleanheapsnaps` Remove any existing heap snapshot (`*.heapsnapshot`) files before the test. + * - `clean` Remove any existing heap snapshot (`*.heapsnapshot`), allocation profiles (`*.heapprofile`) and charts (`.svg`) files before the test. + * - `keepheapsnaps` Keeps the heap snapshots (`*.heapsnapshot`) even if there are no leaks detected. * - `noheapsnaps` Disable taking heap snapshots. * - `moreruns` Does `10` runs instead of the defaults. * - `chart` Writes the memory consumption chart. @@ -144,10 +146,15 @@ export function memtest(opts: MemtestOptions, setup: () => Promise) { runs, }, async ({ expect, task }) => { - if (flags.includes('cleanheapsnaps')) { + if (flags.includes('clean')) { const filesInCwd = await fs.readdir(cwd, { withFileTypes: true }); for (const file of filesInCwd) { - if (file.isFile() && file.name.endsWith('.heapsnapshot')) { + if ( + file.isFile() && + (file.name.endsWith('.heapsnapshot') || + file.name.endsWith('.heapprofile') || + file.name.endsWith('.svg')) + ) { await fs.unlink(path.join(cwd, file.name)); } } @@ -164,6 +171,7 @@ export function memtest(opts: MemtestOptions, setup: () => Promise) { const loadtestResult = await loadtest({ ...loadtestOpts, + id: task.id, cwd, memorySnapshotWindow, takeHeapSnapshots, @@ -250,10 +258,12 @@ ${loadtestResult.heapSnapshots.map(({ file }, index) => `\t${index + 1}. ${path. expect.fail('Expected to diff heap snapshots, but none were taken.'); } - // no leak, remove the heap snapshots - await Promise.all( - loadtestResult.heapSnapshots.map(({ file }) => fs.unlink(file)), - ); + if (!flags.includes('keepheapsnaps')) { + // no leak, remove the heap snapshots + await Promise.all( + loadtestResult.heapSnapshots.map(({ file }) => fs.unlink(file)), + ); + } }, ); } diff --git a/yarn.lock b/yarn.lock index 5eeb470d7..217c53341 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2990,6 +2990,12 @@ __metadata: languageName: unknown linkType: soft +"@e2e/execution-cancellation@workspace:e2e/execution-cancellation": + version: 0.0.0-use.local + resolution: "@e2e/execution-cancellation@workspace:e2e/execution-cancellation" + languageName: unknown + linkType: soft + "@e2e/extra-fields@workspace:e2e/extra-fields": version: 0.0.0-use.local resolution: "@e2e/extra-fields@workspace:e2e/extra-fields"