From 8491cfff66566703c892c130ce13985eafe7b735 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 9 Oct 2025 20:10:30 +0200 Subject: [PATCH 1/9] prepare memtest --- .../execution-cancellation.memtest.ts | 18 ++++++++++++++++++ e2e/execution-cancellation/gateway.config.ts | 5 +++++ e2e/execution-cancellation/package.json | 3 +++ yarn.lock | 6 ++++++ 4 files changed, 32 insertions(+) create mode 100644 e2e/execution-cancellation/execution-cancellation.memtest.ts create mode 100644 e2e/execution-cancellation/gateway.config.ts create mode 100644 e2e/execution-cancellation/package.json diff --git a/e2e/execution-cancellation/execution-cancellation.memtest.ts b/e2e/execution-cancellation/execution-cancellation.memtest.ts new file mode 100644 index 000000000..1c546eb14 --- /dev/null +++ b/e2e/execution-cancellation/execution-cancellation.memtest.ts @@ -0,0 +1,18 @@ +import { createExampleSetup, createTenv } from '@internal/e2e'; +import { memtest } from '@internal/perf/memtest'; + +const cwd = __dirname; + +const { gateway } = createTenv(cwd); +const { supergraph, query } = createExampleSetup(cwd); + +memtest( + { + cwd, + query, + }, + async () => + gateway({ + supergraph: await supergraph(), + }), +); 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..792d45c1c --- /dev/null +++ b/e2e/execution-cancellation/package.json @@ -0,0 +1,3 @@ +{ + "name": "@e2e/execution-cancellation" +} 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" From 959c8f6985ed273162dbdefd092ca0c6f7f96d69 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 9 Oct 2025 20:17:54 +0200 Subject: [PATCH 2/9] use task id --- internal/perf/src/loadtest.ts | 7 ++++++- internal/perf/src/memtest.ts | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) 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..04d827c50 100644 --- a/internal/perf/src/memtest.ts +++ b/internal/perf/src/memtest.ts @@ -164,6 +164,7 @@ export function memtest(opts: MemtestOptions, setup: () => Promise) { const loadtestResult = await loadtest({ ...loadtestOpts, + id: task.id, cwd, memorySnapshotWindow, takeHeapSnapshots, @@ -250,6 +251,8 @@ ${loadtestResult.heapSnapshots.map(({ file }, index) => `\t${index + 1}. ${path. expect.fail('Expected to diff heap snapshots, but none were taken.'); } + return; + // no leak, remove the heap snapshots await Promise.all( loadtestResult.heapSnapshots.map(({ file }) => fs.unlink(file)), From 72d98b809d6a1b6e3dbc5de9d680a4aaea617f6c Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 9 Oct 2025 20:19:06 +0200 Subject: [PATCH 3/9] keepheapsnaps --- internal/perf/src/memtest.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/perf/src/memtest.ts b/internal/perf/src/memtest.ts index 04d827c50..7a223657e 100644 --- a/internal/perf/src/memtest.ts +++ b/internal/perf/src/memtest.ts @@ -17,6 +17,7 @@ const __project = path.resolve(__dirname, '..', '..', '..'); const supportedFlags = [ 'short' as const, 'cleanheapsnaps' as const, + 'keepheapsnaps' as const, 'noheapsnaps' as const, 'moreruns' as const, 'chart' as const, @@ -29,6 +30,7 @@ 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. + * - `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. @@ -251,12 +253,12 @@ ${loadtestResult.heapSnapshots.map(({ file }, index) => `\t${index + 1}. ${path. expect.fail('Expected to diff heap snapshots, but none were taken.'); } - return; - - // 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)), + ); + } }, ); } From 0ad69f2d3e256e9bef097ddc4c70044f9f58e4e4 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 9 Oct 2025 20:24:08 +0200 Subject: [PATCH 4/9] clean flag --- internal/perf/src/memtest.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/internal/perf/src/memtest.ts b/internal/perf/src/memtest.ts index 7a223657e..edb4705ef 100644 --- a/internal/perf/src/memtest.ts +++ b/internal/perf/src/memtest.ts @@ -16,7 +16,7 @@ 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, @@ -29,7 +29,7 @@ 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. @@ -146,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)); } } From 15d218436ef4365f543587e4a5ada90b4cfd6027 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 9 Oct 2025 20:36:31 +0200 Subject: [PATCH 5/9] test yoga and gateway --- .github/workflows/memtest.yml | 1 + .../execution-cancellation.memtest.ts | 58 +++++++++++++++---- e2e/execution-cancellation/yoga-server.ts | 33 +++++++++++ 3 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 e2e/execution-cancellation/yoga-server.ts 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 index 1c546eb14..180d80530 100644 --- a/e2e/execution-cancellation/execution-cancellation.memtest.ts +++ b/e2e/execution-cancellation/execution-cancellation.memtest.ts @@ -1,18 +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); -memtest( - { - cwd, - query, - }, - async () => - gateway({ - supergraph: await supergraph(), - }), -); +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/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'); +}); From 5dc020a4c57543a267de9af12a428d04c6d82750 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 9 Oct 2025 20:47:39 +0200 Subject: [PATCH 6/9] ci run From af2bbcd5d1aa4440c7d9349f4f27347006780a1d Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 9 Oct 2025 21:00:09 +0200 Subject: [PATCH 7/9] e2e are private --- e2e/execution-cancellation/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/execution-cancellation/package.json b/e2e/execution-cancellation/package.json index 792d45c1c..0cdcba9b6 100644 --- a/e2e/execution-cancellation/package.json +++ b/e2e/execution-cancellation/package.json @@ -1,3 +1,4 @@ { - "name": "@e2e/execution-cancellation" + "name": "@e2e/execution-cancellation", + "private": true } From 09329692af58dcc3ff1bd73b714d0e6af2899a18 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 10 Oct 2025 11:36:08 +0200 Subject: [PATCH 8/9] make sure content type json --- internal/perf/src/graphql-loadtest-script.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/perf/src/graphql-loadtest-script.ts b/internal/perf/src/graphql-loadtest-script.ts index 46114c935..ce639426c 100644 --- a/internal/perf/src/graphql-loadtest-script.ts +++ b/internal/perf/src/graphql-loadtest-script.ts @@ -13,7 +13,11 @@ export default function () { return test.abort('Environment variable "QUERY" not provided'); } - const res = http.post(url, { query }); + const res = http.post( + url, + { query }, + { headers: { 'content-type': 'application/json' } }, + ); if (__ENV['ALLOW_FAILING_REQUESTS']) { check(res, { From 246ca3bc36aaa3c1b9ecdc2d8874aa4f25f98bc0 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 10 Oct 2025 11:37:15 +0200 Subject: [PATCH 9/9] ensure body stringified json --- internal/perf/src/graphql-loadtest-script.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/internal/perf/src/graphql-loadtest-script.ts b/internal/perf/src/graphql-loadtest-script.ts index ce639426c..93e8f5d07 100644 --- a/internal/perf/src/graphql-loadtest-script.ts +++ b/internal/perf/src/graphql-loadtest-script.ts @@ -13,11 +13,9 @@ export default function () { return test.abort('Environment variable "QUERY" not provided'); } - const res = http.post( - url, - { query }, - { headers: { 'content-type': 'application/json' } }, - ); + const res = http.post(url, JSON.stringify({ query }), { + headers: { 'content-type': 'application/json' }, + }); if (__ENV['ALLOW_FAILING_REQUESTS']) { check(res, {