Skip to content

Commit 48c6e96

Browse files
authored
Stresstest and memtest the Hive Logger (#1591)
1 parent 8c4138d commit 48c6e96

File tree

9 files changed

+312
-36
lines changed

9 files changed

+312
-36
lines changed

.github/workflows/memtest.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ jobs:
2525
- federation-subscriptions-passthrough
2626
- opentelemetry
2727
- programmatic-batching
28+
- logger
2829
e2e_runner:
2930
- node
3031
# - bun TODO: get memory snaps and heap sampling for bun. is it even necessary?
@@ -65,4 +66,6 @@ jobs:
6566
uses: actions/upload-artifact@v4
6667
with:
6768
name: ${{matrix.test_name}}-heap-snapshots
68-
path: e2e/${{matrix.test_name}}/*.heapsnapshot
69+
path: |
70+
e2e/${{matrix.test_name}}/*.heapsnapshot
71+
packages/logger/tests/*.heapsnapshot

.gitignore

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,7 @@ hive-gateway
1616
/examples/**/*/supergraph.graphql
1717
.helix/config.toml
1818
.helix/languages.toml
19-
/e2e/**/*/memtest-memory-usage_*.svg
20-
/e2e/**/*/*.heapsnapshot
21-
/e2e/**/*/*.heapprofile
22-
/e2e/**/*/*.out
23-
/internal/**/*/*.heapsnapshot
24-
/internal/**/*/*.heapprofile
25-
/internal/**/*/*.out
19+
memtest-*.svg
20+
*.heapsnapshot
21+
*.heapprofile
22+
*.out
File renamed without changes.

internal/perf/src/loadtest.ts

Lines changed: 65 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,20 @@ export interface LoadtestOptions extends ProcOptions {
1919
calmdown: number;
2020
/** How many times to run the loadtests? */
2121
runs: number;
22-
/** The snapshotting window of the GraphQL server memory in milliseconds. */
22+
/** The snapshotting window of the server memory in milliseconds. */
2323
memorySnapshotWindow: number;
24-
/** The GraphQL server on which the loadtest is running. */
24+
/** The server on which the loadtest is running. */
2525
server: Server;
2626
/**
2727
* The GraphQL query to execute for the loadtest.
28+
* Either `query` or `pathname` must be provided.
2829
*/
29-
query: string;
30+
query?: string;
31+
/**
32+
* The HTTP pathname to request for the loadtest.
33+
* Either `query` or `pathname` must be provided.
34+
*/
35+
pathname?: string;
3036
/**
3137
* Whether to take heap snapshots on the end of the `idle` phase and then at the end
3238
* of the `calmdown` {@link LoadtestPhase phase} in each of the {@link runs}.
@@ -104,6 +110,7 @@ export async function loadtest(opts: LoadtestOptions): Promise<{
104110
memorySnapshotWindow,
105111
server,
106112
query,
113+
pathname,
107114
takeHeapSnapshots,
108115
performHeapSampling,
109116
allowFailingRequests,
@@ -120,36 +127,58 @@ export async function loadtest(opts: LoadtestOptions): Promise<{
120127
throw new Error(`At least one run is necessary, got "${runs}"`);
121128
}
122129

130+
if (!query && !pathname) {
131+
throw new Error('Either "query" or "pathname" must be provided');
132+
}
133+
134+
if (query && pathname) {
135+
throw new Error('Cannot provide both "query" and "pathname"');
136+
}
137+
123138
const startTime = new Date();
124139

125140
// we create a random id to make sure the heapsnapshot files are unique and easily distinguishable in the filesystem
126141
// when running multiple loadtests in parallel. see e2e/opentelemetry memtest as an example
127142
const id = Math.random().toString(36).slice(2, 6);
128143

129-
// make sure the query works before starting the loadtests
130-
// the request here matches the request done in loadtest-script.ts
131-
const res = await fetch(
132-
`${server.protocol}://localhost:${server.port}/graphql`,
133-
{
134-
method: 'POST',
135-
headers: {
136-
'content-type': 'application/json',
144+
// make sure the endpoint works before starting the loadtests
145+
// the request here matches the request done in loadtest-script.ts or http-loadtest-script.ts
146+
if (query) {
147+
const res = await fetch(
148+
`${server.protocol}://localhost:${server.port}/graphql`,
149+
{
150+
method: 'POST',
151+
headers: {
152+
'content-type': 'application/json',
153+
},
154+
body: JSON.stringify({ query }),
137155
},
138-
body: JSON.stringify({ query }),
139-
},
140-
);
141-
const text = await res.text();
142-
if (!res.ok) {
143-
const err = new Error(
144-
`Status is not 200, got status ${res.status} ${res.statusText} and body:\n${text}`,
145156
);
146-
err.name = 'ResponseError';
147-
throw err;
148-
}
149-
if (!text.includes('"data":{')) {
150-
const err = new Error(`Body does not contain "data":\n${text}`);
151-
err.name = 'ResponseError';
152-
throw err;
157+
const text = await res.text();
158+
if (!res.ok) {
159+
const err = new Error(
160+
`Status is not 200, got status ${res.status} ${res.statusText} and body:\n${text}`,
161+
);
162+
err.name = 'ResponseError';
163+
throw err;
164+
}
165+
if (!text.includes('"data":{')) {
166+
const err = new Error(`Body does not contain "data":\n${text}`);
167+
err.name = 'ResponseError';
168+
throw err;
169+
}
170+
} else if (pathname) {
171+
const res = await fetch(
172+
`${server.protocol}://localhost:${server.port}${pathname}`,
173+
);
174+
if (!res.ok) {
175+
const text = await res.text();
176+
const err = new Error(
177+
`Status is not 200, got status ${res.status} ${res.statusText} and body:\n${text}`,
178+
);
179+
err.name = 'ResponseError';
180+
throw err;
181+
}
153182
}
154183

155184
const ctrl = new AbortController();
@@ -244,9 +273,17 @@ export async function loadtest(opts: LoadtestOptions): Promise<{
244273
'run',
245274
`--vus=${vus}`,
246275
`--duration=${duration}ms`,
247-
`--env=URL=${server.protocol}://localhost:${server.port}/graphql`,
248-
`--env=QUERY=${query}`,
249-
path.join(__dirname, 'loadtest-script.ts'),
276+
// graphql
277+
query &&
278+
`--env=URL=${server.protocol}://localhost:${server.port}/graphql`,
279+
query && `--env=QUERY=${query}`,
280+
// pathname
281+
pathname &&
282+
`--env=URL=${server.protocol}://localhost:${server.port}${pathname}`,
283+
path.join(
284+
__dirname,
285+
query ? 'graphql-loadtest-script.ts' : 'pathname-loadtest-script.ts',
286+
),
250287
);
251288
await Promise.race([waitForExit, serverThrowOnExit, memorySnapshotting]);
252289

internal/perf/src/memtest.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,19 @@ export interface MemtestOptions
5454
| 'calmdown'
5555
| 'runs'
5656
| 'server'
57+
| 'query'
58+
| 'pathname'
5759
> {
60+
/**
61+
* The GraphQL query to execute for the loadtest.
62+
* Either `query` or `pathname` must be provided.
63+
*/
64+
query?: string;
65+
/**
66+
* The HTTP pathname to request for the loadtest.
67+
* Either `query` or `pathname` must be provided.
68+
*/
69+
pathname?: string;
5870
/**
5971
* The snapshotting window of the GraphQL server memory in milliseconds.
6072
*
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { check } from 'k6';
2+
import { test } from 'k6/execution';
3+
import http from 'k6/http';
4+
5+
export default function () {
6+
const url = __ENV['URL'];
7+
if (!url) {
8+
return test.abort('Environment variable "URL" not provided');
9+
}
10+
11+
const res = http.get(url);
12+
13+
if (__ENV['ALLOW_FAILING_REQUESTS']) {
14+
check(res, {
15+
'status is 200': (res) => res.status === 200,
16+
});
17+
} else {
18+
const body = res.body?.toString() || '';
19+
if (res.status !== 200) {
20+
return test.abort(
21+
`Status is not 200, got status ${res.status} and body:\n${body}`,
22+
);
23+
}
24+
}
25+
}

internal/proc/src/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ export function spawn(
9090
let stdout = '';
9191
let stderr = '';
9292
let stdboth = '';
93+
// Limit stored output to prevent memory issues with high-volume logging
94+
// Keep last 10MB of output (approximately 10 million characters)
95+
const MAX_OUTPUT_LENGTH = 10_000_000;
9396
const proc: Proc = {
9497
waitForExit,
9598
kill(signal) {
@@ -141,13 +144,25 @@ export function spawn(
141144
child.stdout.on('data', (x) => {
142145
const str = x.toString();
143146
stdout += str;
147+
if (stdout.length > MAX_OUTPUT_LENGTH) {
148+
stdout = stdout.slice(-MAX_OUTPUT_LENGTH);
149+
}
144150
stdboth += str;
151+
if (stdboth.length > MAX_OUTPUT_LENGTH) {
152+
stdboth = stdboth.slice(-MAX_OUTPUT_LENGTH);
153+
}
145154
pipeLog({ cwd, pipeLogs }, x);
146155
});
147156
child.stderr.on('data', (x) => {
148157
const str = replaceStderr(x.toString());
149158
stderr += str;
159+
if (stderr.length > MAX_OUTPUT_LENGTH) {
160+
stderr = stderr.slice(-MAX_OUTPUT_LENGTH);
161+
}
150162
stdboth += str;
163+
if (stdboth.length > MAX_OUTPUT_LENGTH) {
164+
stdboth = stdboth.slice(-MAX_OUTPUT_LENGTH);
165+
}
151166
pipeLog({ cwd, pipeLogs }, x);
152167
});
153168

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { createServer } from 'node:http';
2+
import { Logger } from '../src/logger';
3+
4+
const syncLogger = new Logger({
5+
level: 'debug',
6+
writers: [
7+
{
8+
write: () => {
9+
// noop
10+
},
11+
},
12+
],
13+
});
14+
15+
const asyncLogger = new Logger({
16+
level: 'debug',
17+
writers: [
18+
{
19+
// Simulate async I/O with minimal delay
20+
write: () => new Promise((resolve) => setImmediate(resolve)),
21+
},
22+
],
23+
});
24+
25+
// Large object for attribute testing
26+
const largeObject = {
27+
data: new Array(100).fill(null).map((_, j) => ({
28+
index: j,
29+
value: `data-value-${j}`,
30+
nested: {
31+
level1: { level2: { level3: `nested-${j}` } },
32+
},
33+
})),
34+
};
35+
36+
// Circular reference object
37+
const circularObj: any = { id: 1, data: 'test' };
38+
circularObj.self = circularObj;
39+
circularObj.nested = { parent: circularObj };
40+
41+
const server = createServer((req, res) => {
42+
try {
43+
const parts = req.url?.split('/') || [];
44+
const name = parts[parts.length - 1];
45+
switch (name) {
46+
case 'sync-logging':
47+
// High-volume synchronous logging
48+
syncLogger.info(
49+
{ iteration: Date.now(), data: 'test data' },
50+
'Sync message',
51+
);
52+
break;
53+
54+
case 'async-logging':
55+
// Async logging with I/O simulation
56+
asyncLogger.info(
57+
{ iteration: Date.now(), data: 'test data' },
58+
'Async message',
59+
);
60+
break;
61+
62+
case 'child-loggers': {
63+
// Create child loggers
64+
const child1 = syncLogger.child({ parent: 'root' }, 'child1 ');
65+
const child2 = child1.child({ parent: 'child1' }, 'child2 ');
66+
child2.info('Deep child message');
67+
break;
68+
}
69+
70+
case 'child-loggers-large': {
71+
// Create child loggers
72+
const child1 = syncLogger.child(
73+
{ parent: 'root', l1: largeObject },
74+
'child1 ',
75+
);
76+
const child2 = child1.child(
77+
{ parent: 'child1', l2: largeObject },
78+
'child2 ',
79+
);
80+
child2.info(largeObject, 'Large object message');
81+
break;
82+
}
83+
84+
case 'large-attributes':
85+
// Log large attribute objects
86+
syncLogger.info(largeObject, 'Large object message');
87+
break;
88+
89+
case 'circular-refs':
90+
// Log circular references
91+
syncLogger.info(circularObj, 'Circular message');
92+
break;
93+
94+
case 'lazy-attributes':
95+
// Log with lazy attributes
96+
syncLogger.info(
97+
() => ({
98+
id: Date.now(),
99+
data: new Array(10).fill(null).map((_, j) => `item-${j}`),
100+
timestamp: Date.now(),
101+
}),
102+
'Lazy message',
103+
);
104+
break;
105+
106+
case 'mixed-levels':
107+
// Log at different levels
108+
syncLogger.trace('Trace message');
109+
syncLogger.debug('Debug message');
110+
syncLogger.info('Info message');
111+
syncLogger.warn('Warn message');
112+
syncLogger.error('Error message');
113+
break;
114+
115+
case 'level-changes':
116+
// Change log levels
117+
syncLogger.setLevel('debug');
118+
syncLogger.debug('Debug enabled');
119+
syncLogger.setLevel('info');
120+
syncLogger.info('Back to info');
121+
break;
122+
123+
default:
124+
res.writeHead(404).end();
125+
return;
126+
}
127+
res.writeHead(200).end();
128+
} catch (err) {
129+
res.writeHead(500).end((err as Error).stack);
130+
}
131+
});
132+
133+
server.listen(parseInt(process.env['PORT']!));

0 commit comments

Comments
 (0)