Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Loadtest E2Es and monitor memory for leak detection #611

Draft
wants to merge 19 commits into
base: main
Choose a base branch
from
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
4 changes: 1 addition & 3 deletions .github/workflows/bench.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ concurrency:

env:
NODE_NO_WARNINGS: 1
CI: true
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


jobs:
bench:
Expand All @@ -22,7 +21,7 @@ jobs:
- 10
- 100
- 1000
name: Benchmark / ${{matrix.e2e_runner}} / ${{matrix.products_size}} items
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks nicer in the checks overview.

- Bench / Benchmark / node / 10 items
+ Bench / node / 10 items

name: ${{matrix.e2e_runner}} / ${{matrix.products_size}} items
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand All @@ -36,4 +35,3 @@ jobs:
env:
PRODUCTS_SIZE: ${{matrix.products_size}}
E2E_GATEWAY_RUNNER: ${{matrix.e2e_runner}}
CI: true
39 changes: 39 additions & 0 deletions .github/workflows/memtest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Memtest
on:
push:
branches:
- main
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
NODE_NO_WARNINGS: 1
K6_VERSION: v0.56.0

jobs:
memtest:
strategy:
matrix:
e2e_runner: [node, bun]
name: ${{matrix.e2e_runner}}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install k6
run: |
mkdir -p "$HOME/.local/bin"
cd "$HOME/.local/bin"
curl https://github.com/grafana/k6/releases/download/${{ env.K6_VERSION }}/k6-${{ env.K6_VERSION }}-linux-amd64.tar.gz -L | tar xvz --strip-components 1
echo "$PWD" >> $GITHUB_PATH
- name: Set up env
uses: the-guild-org/shared-config/setup@v1
Dismissed Show dismissed Hide dismissed
with:
node-version-file: .node-version
- name: Test
run: yarn test:mem
env:
E2E_GATEWAY_RUNNER: ${{matrix.e2e_runner}}
18 changes: 18 additions & 0 deletions e2e/federation-example/federation-example.memtest.ts
Original file line number Diff line number Diff line change
@@ -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(),
}),
);
15 changes: 8 additions & 7 deletions internal/e2e/src/tenv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
fakePromise,
registerAbortSignalListener,
} from '@graphql-tools/utils';
import { Proc, ProcOptions, spawn, waitForPort } from '@internal/proc';
import { Proc, ProcOptions, Server, spawn, waitForPort } from '@internal/proc';
import {
boolEnv,
createOpt,
Expand Down Expand Up @@ -82,11 +82,6 @@ yarn build && E2E_GATEWAY_RUNNER=bun-docker yarn workspace @graphql-hive/gateway
return runner as ServeRunner;
})();

export interface Server extends Proc {
port: number;
protocol: string;
}

export interface GatewayOptions extends ProcOptions {
port?: number;
/**
Expand Down Expand Up @@ -711,7 +706,12 @@ export function createTenv(cwd: string): Tenv {
gatewayPort && createPortOpt(gatewayPort),
...args,
);
const service: Service = { ...proc, name, port, protocol };
const service: Service = {
...proc,
name,
port,
protocol,
};
await Promise.race([
waitForExit
.then(() => {
Expand Down Expand Up @@ -870,6 +870,7 @@ export function createTenv(cwd: string): Tenv {
await ctr.start();

const container: Container = {
waitForExit: ctr.wait(),
containerName,
name,
port: hostPort,
Expand Down
2 changes: 2 additions & 0 deletions internal/examples/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ export async function convertE2EToExample(config: ConvertE2EToExampleConfig) {
!path.basename(extraDirOrFile).includes('.e2e.') &&
// not a bench
!path.basename(extraDirOrFile).includes('.bench.') &&
// not a memtest
!path.basename(extraDirOrFile).includes('.memtest.') &&
// not a dockerile
!path.basename(extraDirOrFile).includes('Dockerfile') &&
// not test snapshots
Expand Down
14 changes: 14 additions & 0 deletions internal/perf/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "@internal/perf",
"type": "module",
"private": true,
"main": "./src/index.ts",
"dependencies": {
"parse-duration": "^2.0.0",
"regression": "^2.0.1"
},
"devDependencies": {
"@types/k6": "^0.54.2",
"@types/regression": "^2"
}
}
1 change: 1 addition & 0 deletions internal/perf/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './loadtest';
22 changes: 22 additions & 0 deletions internal/perf/src/loadtest-script.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { check } from 'k6';
import { test } from 'k6/execution';
import http from 'k6/http';

export default function () {
const url = __ENV['URL'];
if (!url) {
return test.abort('Environment variable "URL" not provided');
}

const query = __ENV['QUERY'];
if (!query) {
return test.abort('Environment variable "QUERY" not provided');
}

const res = http.post(url, { query });

check(res, {
'status is 200': (res) => res.status === 200,
'body contains data': (res) => !!res.body?.toString().includes('"data":{'),
});
}
99 changes: 99 additions & 0 deletions internal/perf/src/loadtest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import path from 'path';
import { setTimeout } from 'timers/promises';
import { ProcOptions, Server, spawn } from '@internal/proc';
import { trimError } from '@internal/testing';

export interface LoadtestOptions extends ProcOptions {
cwd: string;
/** @default 100 */
vus?: number;
/** Duration of the loadtest in milliseconds. */
duration: number;
/**
* The snapshotting window of the GraphQL server memory in milliseconds.
*
* @default 1_000
*/
memorySnapshotWindow?: number;
/** The GraphQL server on which the loadtest is running. */
server: Server;
/**
* The GraphQL query to execute for the loadtest.
*/
query: string;
}

export async function loadtest(opts: LoadtestOptions) {
const {
cwd,
vus = 100,
duration,
memorySnapshotWindow = 1_000,
server,
query,
...procOptions
} = opts;

if (duration < 3_000) {
throw new Error(`Duration has to be at least 3s, got "${duration}"`);
}

const ctrl = new AbortController();
const signal = AbortSignal.any([
ctrl.signal,
AbortSignal.timeout(
duration +
// allow 1s for the k6 process to exit gracefully
1_000,
),
]);

const [, waitForExit] = await spawn(
{
cwd,
...procOptions,
signal,
},
'k6',
'run',
`--vus=${vus}`,
`--duration=${duration}ms`,
`--env=URL=${server.protocol}://localhost:${server.port}/graphql`,
`--env=QUERY=${query}`,
path.join(__dirname, 'loadtest-script.ts'),
);
using _ = {
[Symbol.dispose]() {
ctrl.abort();
},
};

const memoryInMBSnapshots: number[] = [];

// we dont use a `setInterval` because the proc.getStats is async and we want stats ordered by time
(async () => {
while (!signal.aborted) {
await setTimeout(memorySnapshotWindow);
try {
const { mem } = await server.getStats();
memoryInMBSnapshots.push(mem);
} catch (err) {
if (!signal.aborted) {
throw err;
}
return; // couldve been aborted after timeout or while waiting for stats
}
}
})();

await Promise.race([
waitForExit,
server.waitForExit.then(() => {
throw new Error(
`Server exited before the loadtest finished\n${trimError(server.getStd('both'))}`,
);
}),
]);

return { memoryInMBSnapshots };
}
72 changes: 72 additions & 0 deletions internal/perf/src/memtest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Server } from '@internal/proc';
import regression from 'regression';
import { it } from 'vitest';
import { loadtest, LoadtestOptions } from './loadtest';

export interface MemtestOptions
extends Omit<LoadtestOptions, 'duration' | 'server'> {
/**
* Duration of the loadtest in milliseconds.
*
* @default 60_000
*/
duration?: number;
/**
* Linear regression line slope threshold of the memory snapshots.
* If the slope is greater than this value, the test will fail.
*
* @default 5
*/
memoryThresholdInMB?: number;
}

export function memtest(opts: MemtestOptions, setup: () => Promise<Server>) {
const { memoryThresholdInMB = 5, duration = 60_000, ...loadtestOpts } = opts;
it(
'should not have a memory increase trend',
{
timeout: duration + 10_000, // allow 10s for the test teardown
},
async ({ expect }) => {
const server = await setup();

const { memoryInMBSnapshots } = await loadtest({
...loadtestOpts,
duration,
server,
});

const slope = calculateRegressionSlope(memoryInMBSnapshots);

expect(
slope,
`Memory increase trend detected with slope of ${slope}MB (exceding threshold of ${memoryThresholdInMB}MB)`,
).toBeLessThan(memoryThresholdInMB);
},
);
}

/**
* Detects a memory increase trend in an array of memory snapshots over time using linear regression.
*
* @param snapshots - An array of memory snapshots in MB.
*
* @returns The slope of the linear regression line.
*/
function calculateRegressionSlope(snapshots: number[]) {
if (snapshots.length < 2) {
throw new Error('Not enough snapshots to determine trend');
}

const data: [x: number, y: number][] = snapshots.map((memInMB, timestamp) => [
timestamp,
memInMB,
]);
const result = regression.linear(data);
const slope = result.equation[0];
if (!slope) {
throw new Error('Regression slope is zero');
}

return slope;
}
7 changes: 7 additions & 0 deletions internal/proc/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import terminate from 'terminate/promise';

export interface Proc extends AsyncDisposable {
waitForExit: Promise<void>;
getStd(o: 'out' | 'err' | 'both'): string;
getStats(): Promise<{
// Total CPU utilization (of all cores) as a percentage.
Expand All @@ -18,6 +19,11 @@
}>;
}

export interface Server extends Proc {
port: number;
protocol: string;
}

export interface ProcOptions {
/**
* Pipe the logs from the spawned process to the current process, or to a file
Expand Down Expand Up @@ -79,6 +85,7 @@
let stderr = '';
let stdboth = '';
const proc: Proc = {
waitForExit,
getStd(o) {
switch (o) {
case 'out':
Expand Down Expand Up @@ -145,7 +152,7 @@
// process ended _and_ the stdio streams have been closed
if (code) {
exitDeferred.reject(
new Error(

Check failure on line 155 in internal/proc/src/index.ts

View workflow job for this annotation

GitHub Actions / E2E / Node Binary on Windows

e2e/federation-batching-plan/federation-batching-plan.e2e.ts > should consistently explain the query plan

Error: Exit code 1 from D:\a\gateway\gateway\packages\gateway\hive-gateway --port=52480 supergraph C:\Users\RUNNER~1\AppData\Local\Temp\hive-gateway_e2e_fsDz0BDo\supergraph.graphql node:internal/modules/cjs/loader:1397 throw err; ^ Error: Cannot find module './version.js' Require stack: - C:\Users\RUNNER~1\AppData\Local\Temp\graphql-hive__gateway_645b4cc3d08220eb419ebd96d368a167b4e0940aa92e98891e460972f4aec36e_node_modules\graphql\index.js - packages\gateway\hive-gateway.exe at node:internal/modules/cjs/loader:1394:15 at Module._resolveFilename (bundle/hive-gateway.cjs:3419:14) at defaultResolveImpl (node:internal/modules/cjs/loader:1050:19) at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1055:22) at Function._load (node:internal/modules/cjs/loader:1204:37) at TracingChannel.traceSync (node:diagnostics_channel:322:14) at wrapModuleLoad (node:internal/modules/cjs/loader:234:24) at Module.require (node:internal/modules/cjs/loader:1480:12) at require (node:internal/modules/helpers:135:16) at Object.<anonymous> (C:\Users\RUNNER~1\AppData\Local\Temp\graphql-hive__gateway_645b4cc3d08220eb419ebd96d368a167b4e0940aa92e98891e460972f4aec36e_node_modules\graphql\index.js:1273:16) { code: 'MODULE_NOT_FOUND', requireStack: [ 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\graphql-hive__gateway_645b4cc3d08220eb419ebd96d368a167b4e0940aa92e98891e460972f4aec36e_node_modules\\graphql\\index.js', 'D:\\a\\gateway\\gateway\\packages\\gateway\\hive-gateway.exe' ] } Node.js v23.7.0 ❯ Module._resolveFilename bundle/hive-gateway.cjs:3419:14 ❯ ChildProcess.<anonymous> internal/proc/src/index.ts:155:9
`Exit code ${code} from ${cmd} ${args.join(' ')}\n${trimError(stdboth)}`,
),
);
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"test": "vitest --project unit",
"test:bun": "bun test --bail",
"test:e2e": "vitest --project e2e",
"test:leaks": "cross-env \"LEAK_TEST=1\" jest --detectOpenHandles --detectLeaks"
"test:leaks": "cross-env \"LEAK_TEST=1\" jest --detectOpenHandles --detectLeaks",
"test:mem": "vitest --project memtest"
},
"devDependencies": {
"@babel/core": "7.26.7",
Expand Down
2 changes: 2 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"@internal/testing": ["./internal/testing/src/index.ts"],
"@internal/e2e": ["./internal/e2e/src/index.ts"],
"@internal/proc": ["./internal/proc/src/index.ts"],
"@internal/perf": ["./internal/perf/src/index.ts"],
"@internal/perf/memtest": ["./internal/perf/src/memtest.ts"],
"@internal/testing/to-be-similar-string": [
"./internal/testing/src/to-be-similar-string.ts"
],
Expand Down
Loading
Loading