@donkeylabs/server 2.0.19 → 2.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/caching-strategies.md +677 -0
- package/docs/dev-experience.md +656 -0
- package/docs/hot-reload-limitations.md +166 -0
- package/docs/load-testing.md +974 -0
- package/docs/plugin-registry-design.md +1064 -0
- package/docs/production.md +1229 -0
- package/docs/workflows.md +90 -3
- package/package.json +1 -1
- package/src/admin/routes.ts +153 -0
- package/src/core/cron.ts +90 -9
- package/src/core/index.ts +31 -0
- package/src/core/job-adapter-kysely.ts +176 -73
- package/src/core/job-adapter-sqlite.ts +10 -0
- package/src/core/jobs.ts +112 -17
- package/src/core/migrations/workflows/002_add_metadata_column.ts +28 -0
- package/src/core/process-adapter-kysely.ts +62 -21
- package/src/core/storage-adapter-local.test.ts +199 -0
- package/src/core/storage.test.ts +197 -0
- package/src/core/workflow-adapter-kysely.ts +66 -19
- package/src/core/workflow-executor.ts +239 -0
- package/src/core/workflow-proxy.ts +238 -0
- package/src/core/workflow-socket.ts +449 -0
- package/src/core/workflow-state-machine.ts +593 -0
- package/src/core/workflows.test.ts +758 -0
- package/src/core/workflows.ts +705 -595
- package/src/core.ts +17 -6
- package/src/index.ts +14 -0
- package/src/testing/database.test.ts +263 -0
- package/src/testing/database.ts +173 -0
- package/src/testing/e2e.test.ts +189 -0
- package/src/testing/e2e.ts +272 -0
- package/src/testing/index.ts +18 -0
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
# Load Testing Guide
|
|
2
|
+
|
|
3
|
+
Performance testing for DonkeyLabs applications using k6 and Artillery.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Getting Started with k6](#getting-started-with-k6)
|
|
8
|
+
- [Basic Load Tests](#basic-load-tests)
|
|
9
|
+
- [Advanced Scenarios](#advanced-scenarios)
|
|
10
|
+
- [Testing DonkeyLabs Specifics](#testing-donkeylabs-specifics)
|
|
11
|
+
- [Performance Benchmarks](#performance-benchmarks)
|
|
12
|
+
- [Continuous Load Testing](#continuous-load-testing)
|
|
13
|
+
- [Troubleshooting Performance Issues](#troubleshooting-performance-issues)
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Getting Started with k6
|
|
18
|
+
|
|
19
|
+
### Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# macOS
|
|
23
|
+
brew install k6
|
|
24
|
+
|
|
25
|
+
# Linux
|
|
26
|
+
curl -s https://packagecloud.io/install/repositories/loadimpact/stable/script.deb.sh | sudo bash
|
|
27
|
+
sudo apt-get install k6
|
|
28
|
+
|
|
29
|
+
# Docker
|
|
30
|
+
docker pull grafana/k6
|
|
31
|
+
|
|
32
|
+
# Verify
|
|
33
|
+
k6 version
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Your First Test
|
|
37
|
+
|
|
38
|
+
```javascript
|
|
39
|
+
// load-tests/smoke-test.js
|
|
40
|
+
import http from 'k6/http';
|
|
41
|
+
import { check, sleep } from 'k6';
|
|
42
|
+
|
|
43
|
+
export const options = {
|
|
44
|
+
vus: 10, // Virtual users
|
|
45
|
+
duration: '30s', // Test duration
|
|
46
|
+
thresholds: {
|
|
47
|
+
http_req_duration: ['p(95)<500'], // 95% under 500ms
|
|
48
|
+
http_req_failed: ['rate<0.1'], // Error rate < 10%
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export default function () {
|
|
53
|
+
const res = http.get('http://localhost:3000/health');
|
|
54
|
+
|
|
55
|
+
check(res, {
|
|
56
|
+
'status is 200': (r) => r.status === 200,
|
|
57
|
+
'response time < 500ms': (r) => r.timings.duration < 500,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
sleep(1);
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# Run smoke test
|
|
66
|
+
k6 run load-tests/smoke-test.js
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Basic Load Tests
|
|
72
|
+
|
|
73
|
+
### 1. Smoke Test (Validate System)
|
|
74
|
+
|
|
75
|
+
```javascript
|
|
76
|
+
// load-tests/smoke.js
|
|
77
|
+
import http from 'k6/http';
|
|
78
|
+
import { check } from 'k6';
|
|
79
|
+
|
|
80
|
+
export const options = {
|
|
81
|
+
vus: 1,
|
|
82
|
+
iterations: 1,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export default function () {
|
|
86
|
+
// Test critical endpoints
|
|
87
|
+
const checks = [
|
|
88
|
+
http.get('http://localhost:3000/health'),
|
|
89
|
+
http.get('http://localhost:3000/users.list'),
|
|
90
|
+
http.post('http://localhost:3000/users.create', {
|
|
91
|
+
email: 'test@example.com',
|
|
92
|
+
name: 'Test User',
|
|
93
|
+
}),
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
checks.forEach((res, i) => {
|
|
97
|
+
check(res, {
|
|
98
|
+
[`endpoint ${i} status 200`]: (r) => r.status === 200,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### 2. Load Test (Normal Traffic)
|
|
105
|
+
|
|
106
|
+
```javascript
|
|
107
|
+
// load-tests/load.js
|
|
108
|
+
import http from 'k6/http';
|
|
109
|
+
import { check, sleep } from 'k6';
|
|
110
|
+
|
|
111
|
+
export const options = {
|
|
112
|
+
stages: [
|
|
113
|
+
{ duration: '2m', target: 50 }, // Ramp up
|
|
114
|
+
{ duration: '5m', target: 50 }, // Stay at 50
|
|
115
|
+
{ duration: '2m', target: 100 }, // Ramp up
|
|
116
|
+
{ duration: '5m', target: 100 }, // Stay at 100
|
|
117
|
+
{ duration: '2m', target: 0 }, // Ramp down
|
|
118
|
+
],
|
|
119
|
+
thresholds: {
|
|
120
|
+
http_req_duration: ['p(95)<500'],
|
|
121
|
+
http_req_failed: ['rate<0.1'],
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
|
|
126
|
+
|
|
127
|
+
export default function () {
|
|
128
|
+
// Simulate user flow
|
|
129
|
+
|
|
130
|
+
// 1. List users
|
|
131
|
+
const listRes = http.get(`${BASE_URL}/users.list`);
|
|
132
|
+
check(listRes, {
|
|
133
|
+
'list status 200': (r) => r.status === 200,
|
|
134
|
+
'list response time < 500ms': (r) => r.timings.duration < 500,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
sleep(2);
|
|
138
|
+
|
|
139
|
+
// 2. Get specific user
|
|
140
|
+
const userId = 'user-123'; // In real test, get from list response
|
|
141
|
+
const getRes = http.get(`${BASE_URL}/users.get?id=${userId}`);
|
|
142
|
+
check(getRes, {
|
|
143
|
+
'get status 200': (r) => r.status === 200,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
sleep(3);
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### 3. Stress Test (Find Breaking Point)
|
|
151
|
+
|
|
152
|
+
```javascript
|
|
153
|
+
// load-tests/stress.js
|
|
154
|
+
import http from 'k6/http';
|
|
155
|
+
import { check } from 'k6';
|
|
156
|
+
|
|
157
|
+
export const options = {
|
|
158
|
+
stages: [
|
|
159
|
+
{ duration: '2m', target: 100 }, // Below normal
|
|
160
|
+
{ duration: '5m', target: 100 }, // Normal
|
|
161
|
+
{ duration: '2m', target: 200 }, // Above normal
|
|
162
|
+
{ duration: '5m', target: 200 }, // Stress
|
|
163
|
+
{ duration: '2m', target: 300 }, // Breaking point
|
|
164
|
+
{ duration: '5m', target: 300 }, // Stay there
|
|
165
|
+
{ duration: '2m', target: 0 }, // Recovery
|
|
166
|
+
],
|
|
167
|
+
thresholds: {
|
|
168
|
+
http_req_duration: ['p(95)<1000'],
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export default function () {
|
|
173
|
+
const res = http.get('http://localhost:3000/users.list');
|
|
174
|
+
|
|
175
|
+
check(res, {
|
|
176
|
+
'status is 200 or 503': (r) => [200, 503].includes(r.status),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### 4. Spike Test (Sudden Traffic)
|
|
182
|
+
|
|
183
|
+
```javascript
|
|
184
|
+
// load-tests/spike.js
|
|
185
|
+
import http from 'k6/http';
|
|
186
|
+
import { check } from 'k6';
|
|
187
|
+
|
|
188
|
+
export const options = {
|
|
189
|
+
stages: [
|
|
190
|
+
{ duration: '10s', target: 100 }, // Baseline
|
|
191
|
+
{ duration: '1m', target: 100 }, // Stay
|
|
192
|
+
{ duration: '10s', target: 1000 }, // Spike!
|
|
193
|
+
{ duration: '3m', target: 1000 }, // Stay
|
|
194
|
+
{ duration: '10s', target: 100 }, // Drop
|
|
195
|
+
{ duration: '3m', target: 100 }, // Recovery
|
|
196
|
+
{ duration: '10s', target: 0 }, // Done
|
|
197
|
+
],
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export default function () {
|
|
201
|
+
const res = http.get('http://localhost:3000/users.list');
|
|
202
|
+
check(res, {
|
|
203
|
+
'status is acceptable': (r) => [200, 429, 503].includes(r.status),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### 5. Soak Test (Endurance)
|
|
209
|
+
|
|
210
|
+
```javascript
|
|
211
|
+
// load-tests/soak.js
|
|
212
|
+
import http from 'k6/http';
|
|
213
|
+
import { check, sleep } from 'k6';
|
|
214
|
+
|
|
215
|
+
export const options = {
|
|
216
|
+
stages: [
|
|
217
|
+
{ duration: '2m', target: 100 }, // Ramp up
|
|
218
|
+
{ duration: '4h', target: 100 }, // Stay for 4 hours
|
|
219
|
+
{ duration: '2m', target: 0 }, // Ramp down
|
|
220
|
+
],
|
|
221
|
+
thresholds: {
|
|
222
|
+
http_req_duration: ['p(95)<500'],
|
|
223
|
+
http_req_failed: ['rate<0.1'],
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
export default function () {
|
|
228
|
+
// Mix of operations
|
|
229
|
+
const scenarios = [
|
|
230
|
+
() => http.get('http://localhost:3000/users.list'),
|
|
231
|
+
() => http.get('http://localhost:3000/users.get?id=user-1'),
|
|
232
|
+
() => http.post('http://localhost:3000/users.create', {
|
|
233
|
+
email: `user-${Date.now()}@test.com`,
|
|
234
|
+
name: 'Test',
|
|
235
|
+
}),
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
const randomScenario = scenarios[Math.floor(Math.random() * scenarios.length)];
|
|
239
|
+
const res = randomScenario();
|
|
240
|
+
|
|
241
|
+
check(res, {
|
|
242
|
+
'request successful': (r) => r.status === 200,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
sleep(Math.random() * 2 + 1); // Random sleep 1-3s
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Advanced Scenarios
|
|
252
|
+
|
|
253
|
+
### Authentication Flow
|
|
254
|
+
|
|
255
|
+
```javascript
|
|
256
|
+
// load-tests/auth-flow.js
|
|
257
|
+
import http from 'k6/http';
|
|
258
|
+
import { check, sleep } from 'k6';
|
|
259
|
+
|
|
260
|
+
export const options = {
|
|
261
|
+
vus: 50,
|
|
262
|
+
duration: '5m',
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
let authToken = null;
|
|
266
|
+
|
|
267
|
+
export function setup() {
|
|
268
|
+
// Login and get token
|
|
269
|
+
const loginRes = http.post('http://localhost:3000/auth.login', {
|
|
270
|
+
email: 'loadtest@example.com',
|
|
271
|
+
password: 'testpass123',
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
check(loginRes, {
|
|
275
|
+
'login successful': (r) => r.status === 200,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return { token: loginRes.json('token') };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export default function (data) {
|
|
282
|
+
const headers = {
|
|
283
|
+
Authorization: `Bearer ${data.token}`,
|
|
284
|
+
'Content-Type': 'application/json',
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// Authenticated request
|
|
288
|
+
const res = http.get('http://localhost:3000/users.me', { headers });
|
|
289
|
+
|
|
290
|
+
check(res, {
|
|
291
|
+
'authenticated request success': (r) => r.status === 200,
|
|
292
|
+
'has user data': (r) => r.json('id') !== undefined,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
sleep(1);
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### Data-Driven Tests
|
|
300
|
+
|
|
301
|
+
```javascript
|
|
302
|
+
// load-tests/data-driven.js
|
|
303
|
+
import http from 'k6/http';
|
|
304
|
+
import { check } from 'k6';
|
|
305
|
+
import { SharedArray } from 'k6/data';
|
|
306
|
+
|
|
307
|
+
// Load test data
|
|
308
|
+
const users = new SharedArray('users', function () {
|
|
309
|
+
return JSON.parse(open('./data/users.json'));
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
export const options = {
|
|
313
|
+
vus: 10,
|
|
314
|
+
iterations: users.length,
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
export default function () {
|
|
318
|
+
const user = users[__ITER];
|
|
319
|
+
|
|
320
|
+
const res = http.post('http://localhost:3000/users.create', {
|
|
321
|
+
email: user.email,
|
|
322
|
+
name: user.name,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
check(res, {
|
|
326
|
+
'user created': (r) => r.status === 200 || r.status === 409, // 409 if exists
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### WebSocket Testing
|
|
332
|
+
|
|
333
|
+
```javascript
|
|
334
|
+
// load-tests/websocket.js
|
|
335
|
+
import ws from 'k6/ws';
|
|
336
|
+
import { check } from 'k6';
|
|
337
|
+
|
|
338
|
+
export const options = {
|
|
339
|
+
vus: 10,
|
|
340
|
+
duration: '1m',
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
export default function () {
|
|
344
|
+
const url = 'ws://localhost:3000/ws';
|
|
345
|
+
|
|
346
|
+
const res = ws.connect(url, null, function (socket) {
|
|
347
|
+
socket.on('open', () => {
|
|
348
|
+
socket.send(JSON.stringify({ type: 'subscribe', channel: 'updates' }));
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
socket.on('message', (data) => {
|
|
352
|
+
const msg = JSON.parse(data);
|
|
353
|
+
check(msg, {
|
|
354
|
+
'received message': () => msg.type !== undefined,
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
socket.setTimeout(function () {
|
|
359
|
+
socket.close();
|
|
360
|
+
}, 30000);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
check(res, { 'status is 101': (r) => r && r.status === 101 });
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### SSE (Server-Sent Events) Testing
|
|
368
|
+
|
|
369
|
+
```javascript
|
|
370
|
+
// load-tests/sse.js
|
|
371
|
+
import http from 'k6/http';
|
|
372
|
+
import { check } from 'k6';
|
|
373
|
+
|
|
374
|
+
export const options = {
|
|
375
|
+
vus: 50,
|
|
376
|
+
duration: '2m',
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
export default function () {
|
|
380
|
+
// Connect to SSE endpoint
|
|
381
|
+
const res = http.get('http://localhost:3000/sse?channels=updates', {
|
|
382
|
+
headers: {
|
|
383
|
+
Accept: 'text/event-stream',
|
|
384
|
+
},
|
|
385
|
+
responseType: 'text',
|
|
386
|
+
timeout: '120s',
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
check(res, {
|
|
390
|
+
'SSE connection established': (r) => r.status === 200,
|
|
391
|
+
'content-type is event-stream': (r) =>
|
|
392
|
+
r.headers['Content-Type'] === 'text/event-stream',
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// Parse SSE data
|
|
396
|
+
const events = res.body.split('\n\n');
|
|
397
|
+
check(events, {
|
|
398
|
+
'received events': (e) => e.length > 0,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
---
|
|
404
|
+
|
|
405
|
+
## Testing DonkeyLabs Specifics
|
|
406
|
+
|
|
407
|
+
### Testing with Generated Client
|
|
408
|
+
|
|
409
|
+
```javascript
|
|
410
|
+
// load-tests/using-client.js
|
|
411
|
+
import http from 'k6/http';
|
|
412
|
+
import { check } from 'k6';
|
|
413
|
+
|
|
414
|
+
// Simulate the API client structure
|
|
415
|
+
class DonkeyLabsClient {
|
|
416
|
+
constructor(baseUrl) {
|
|
417
|
+
this.baseUrl = baseUrl;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
users = {
|
|
421
|
+
list: () => http.get(`${this.baseUrl}/users.list`),
|
|
422
|
+
get: (id) => http.get(`${this.baseUrl}/users.get?id=${id}`),
|
|
423
|
+
create: (data) =>
|
|
424
|
+
http.post(`${this.baseUrl}/users.create`, JSON.stringify(data), {
|
|
425
|
+
headers: { 'Content-Type': 'application/json' },
|
|
426
|
+
}),
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
orders = {
|
|
430
|
+
list: () => http.get(`${this.baseUrl}/orders.list`),
|
|
431
|
+
create: (data) =>
|
|
432
|
+
http.post(`${this.baseUrl}/orders.create`, JSON.stringify(data), {
|
|
433
|
+
headers: { 'Content-Type': 'application/json' },
|
|
434
|
+
}),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const api = new DonkeyLabsClient(__ENV.BASE_URL || 'http://localhost:3000');
|
|
439
|
+
|
|
440
|
+
export const options = {
|
|
441
|
+
vus: 100,
|
|
442
|
+
duration: '5m',
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
export default function () {
|
|
446
|
+
// Create a user
|
|
447
|
+
const createRes = api.users.create({
|
|
448
|
+
email: `user-${__VU}-${__ITER}@test.com`,
|
|
449
|
+
name: 'Load Test User',
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
check(createRes, {
|
|
453
|
+
'user created': (r) => r.status === 200,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
if (createRes.status === 200) {
|
|
457
|
+
const userId = createRes.json('id');
|
|
458
|
+
|
|
459
|
+
// Get the user
|
|
460
|
+
const getRes = api.users.get(userId);
|
|
461
|
+
check(getRes, {
|
|
462
|
+
'user retrieved': (r) => r.status === 200,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// Create an order for the user
|
|
466
|
+
const orderRes = api.orders.create({
|
|
467
|
+
userId,
|
|
468
|
+
items: [{ productId: 'prod-1', quantity: 2 }],
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
check(orderRes, {
|
|
472
|
+
'order created': (r) => r.status === 200,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
### Plugin Service Testing
|
|
479
|
+
|
|
480
|
+
```javascript
|
|
481
|
+
// load-tests/plugin-test.js
|
|
482
|
+
import http from 'k6/http';
|
|
483
|
+
import { check } from 'k6';
|
|
484
|
+
|
|
485
|
+
// Test specific plugin functionality
|
|
486
|
+
export const options = {
|
|
487
|
+
scenarios: {
|
|
488
|
+
cache_test: {
|
|
489
|
+
executor: 'constant-vus',
|
|
490
|
+
vus: 50,
|
|
491
|
+
duration: '2m',
|
|
492
|
+
exec: 'cacheTest',
|
|
493
|
+
},
|
|
494
|
+
rate_limit_test: {
|
|
495
|
+
executor: 'ramping-vus',
|
|
496
|
+
startVUs: 0,
|
|
497
|
+
stages: [
|
|
498
|
+
{ duration: '1m', target: 100 },
|
|
499
|
+
{ duration: '1m', target: 100 },
|
|
500
|
+
{ duration: '1m', target: 200 },
|
|
501
|
+
],
|
|
502
|
+
exec: 'rateLimitTest',
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
export function cacheTest() {
|
|
508
|
+
// Test cache hit performance
|
|
509
|
+
const res = http.get('http://localhost:3000/users.list');
|
|
510
|
+
|
|
511
|
+
check(res, {
|
|
512
|
+
'cache response fast': (r) => r.timings.duration < 50,
|
|
513
|
+
'status 200': (r) => r.status === 200,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function rateLimitTest() {
|
|
518
|
+
// Test rate limiting
|
|
519
|
+
const res = http.get('http://localhost:3000/api.heavy');
|
|
520
|
+
|
|
521
|
+
check(res, {
|
|
522
|
+
'rate limited or success': (r) =>
|
|
523
|
+
[200, 429].includes(r.status),
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
### Database Load Testing
|
|
529
|
+
|
|
530
|
+
```javascript
|
|
531
|
+
// load-tests/db-heavy.js
|
|
532
|
+
import http from 'k6/http';
|
|
533
|
+
import { check } from 'k6';
|
|
534
|
+
import { randomIntBetween } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
|
|
535
|
+
|
|
536
|
+
export const options = {
|
|
537
|
+
vus: 20,
|
|
538
|
+
duration: '5m',
|
|
539
|
+
thresholds: {
|
|
540
|
+
http_req_duration: ['p(95)<1000'], // DB ops can be slower
|
|
541
|
+
},
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
export default function () {
|
|
545
|
+
// Heavy database operations
|
|
546
|
+
|
|
547
|
+
// Complex query with joins
|
|
548
|
+
const reportRes = http.post(
|
|
549
|
+
'http://localhost:3000/reports.generate',
|
|
550
|
+
JSON.stringify({
|
|
551
|
+
type: 'user_activity',
|
|
552
|
+
dateRange: {
|
|
553
|
+
start: '2024-01-01',
|
|
554
|
+
end: '2024-12-31',
|
|
555
|
+
},
|
|
556
|
+
}),
|
|
557
|
+
{ headers: { 'Content-Type': 'application/json' } }
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
check(reportRes, {
|
|
561
|
+
'report generated': (r) => r.status === 200,
|
|
562
|
+
'report not too slow': (r) => r.timings.duration < 5000,
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// Bulk insert operation
|
|
566
|
+
const bulkRes = http.post(
|
|
567
|
+
'http://localhost:3000/users.bulkCreate',
|
|
568
|
+
JSON.stringify({
|
|
569
|
+
users: Array.from({ length: 10 }, (_, i) => ({
|
|
570
|
+
email: `bulk-${__VU}-${__ITER}-${i}@test.com`,
|
|
571
|
+
name: `User ${i}`,
|
|
572
|
+
})),
|
|
573
|
+
}),
|
|
574
|
+
{ headers: { 'Content-Type': 'application/json' } }
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
check(bulkRes, {
|
|
578
|
+
'bulk insert succeeded': (r) => r.status === 200,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
---
|
|
584
|
+
|
|
585
|
+
## Performance Benchmarks
|
|
586
|
+
|
|
587
|
+
### Establishing Baselines
|
|
588
|
+
|
|
589
|
+
```javascript
|
|
590
|
+
// load-tests/benchmark.js
|
|
591
|
+
import http from 'k6/http';
|
|
592
|
+
import { check, group } from 'k6';
|
|
593
|
+
|
|
594
|
+
export const options = {
|
|
595
|
+
vus: 1,
|
|
596
|
+
iterations: 100,
|
|
597
|
+
thresholds: {
|
|
598
|
+
'http_req_duration{group:::health}': ['avg<10'],
|
|
599
|
+
'http_req_duration{group:::list}': ['avg<100'],
|
|
600
|
+
'http_req_duration{group:::create}': ['avg<200'],
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
export default function () {
|
|
605
|
+
group('health', () => {
|
|
606
|
+
const res = http.get('http://localhost:3000/health');
|
|
607
|
+
check(res, {
|
|
608
|
+
'health check': (r) => r.status === 200,
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
group('list', () => {
|
|
613
|
+
const res = http.get('http://localhost:3000/users.list');
|
|
614
|
+
check(res, {
|
|
615
|
+
'list users': (r) => r.status === 200,
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
group('create', () => {
|
|
620
|
+
const res = http.post(
|
|
621
|
+
'http://localhost:3000/users.create',
|
|
622
|
+
JSON.stringify({
|
|
623
|
+
email: `bench-${Date.now()}@test.com`,
|
|
624
|
+
name: 'Benchmark',
|
|
625
|
+
}),
|
|
626
|
+
{ headers: { 'Content-Type': 'application/json' } }
|
|
627
|
+
);
|
|
628
|
+
check(res, {
|
|
629
|
+
'create user': (r) => r.status === 200,
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
### Run Benchmarks
|
|
636
|
+
|
|
637
|
+
```bash
|
|
638
|
+
# Run and output to JSON for analysis
|
|
639
|
+
k6 run --out json=benchmark-results.json load-tests/benchmark.js
|
|
640
|
+
|
|
641
|
+
# Run with custom thresholds
|
|
642
|
+
k6 run -e THRESHOLD_P95=200 load-tests/benchmark.js
|
|
643
|
+
|
|
644
|
+
# Compare against previous run
|
|
645
|
+
k6 run --out influxdb=http://localhost:8086/k6 load-tests/benchmark.js
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
### Performance Regression Testing
|
|
649
|
+
|
|
650
|
+
```javascript
|
|
651
|
+
// load-tests/regression.js
|
|
652
|
+
import http from 'k6/http';
|
|
653
|
+
import { check, fail } from 'k6';
|
|
654
|
+
|
|
655
|
+
// Baseline metrics from previous runs
|
|
656
|
+
const BASELINE = {
|
|
657
|
+
health_p95: 10,
|
|
658
|
+
list_p95: 100,
|
|
659
|
+
create_p95: 200,
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
// Allow 20% regression
|
|
663
|
+
const THRESHOLD = 1.2;
|
|
664
|
+
|
|
665
|
+
export const options = {
|
|
666
|
+
vus: 50,
|
|
667
|
+
duration: '2m',
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
export default function () {
|
|
671
|
+
const results = {
|
|
672
|
+
health: [],
|
|
673
|
+
list: [],
|
|
674
|
+
create: [],
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
// Collect metrics
|
|
678
|
+
results.health.push(http.get('http://localhost:3000/health').timings.duration);
|
|
679
|
+
results.list.push(http.get('http://localhost:3000/users.list').timings.duration);
|
|
680
|
+
results.create.push(
|
|
681
|
+
http.post('http://localhost:3000/users.create', {
|
|
682
|
+
email: `reg-${Date.now()}@test.com`,
|
|
683
|
+
name: 'Test',
|
|
684
|
+
}).timings.duration
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
// Check for regression (simplified, normally done in handleSummary)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export function handleSummary(data) {
|
|
691
|
+
const checks = {
|
|
692
|
+
health: data.metrics.http_req_duration.percentiles['95'] < BASELINE.health_p95 * THRESHOLD,
|
|
693
|
+
list: data.metrics.http_req_duration.percentiles['95'] < BASELINE.list_p95 * THRESHOLD,
|
|
694
|
+
create: data.metrics.http_req_duration.percentiles['95'] < BASELINE.create_p95 * THRESHOLD,
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
if (!Object.values(checks).every(Boolean)) {
|
|
698
|
+
return {
|
|
699
|
+
stdout: JSON.stringify({
|
|
700
|
+
status: 'FAIL',
|
|
701
|
+
message: 'Performance regression detected',
|
|
702
|
+
checks,
|
|
703
|
+
}),
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return {
|
|
708
|
+
stdout: JSON.stringify({
|
|
709
|
+
status: 'PASS',
|
|
710
|
+
message: 'No regression detected',
|
|
711
|
+
checks,
|
|
712
|
+
}),
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
---
|
|
718
|
+
|
|
719
|
+
## Continuous Load Testing
|
|
720
|
+
|
|
721
|
+
### GitHub Actions Integration
|
|
722
|
+
|
|
723
|
+
```yaml
|
|
724
|
+
# .github/workflows/load-test.yml
|
|
725
|
+
name: Load Test
|
|
726
|
+
|
|
727
|
+
on:
|
|
728
|
+
schedule:
|
|
729
|
+
- cron: '0 2 * * *' # Daily at 2 AM
|
|
730
|
+
workflow_dispatch:
|
|
731
|
+
|
|
732
|
+
jobs:
|
|
733
|
+
load-test:
|
|
734
|
+
runs-on: ubuntu-latest
|
|
735
|
+
|
|
736
|
+
steps:
|
|
737
|
+
- uses: actions/checkout@v3
|
|
738
|
+
|
|
739
|
+
- name: Setup k6
|
|
740
|
+
run: |
|
|
741
|
+
curl -s https://packagecloud.io/install/repositories/loadimpact/stable/script.deb.sh | sudo bash
|
|
742
|
+
sudo apt-get install k6
|
|
743
|
+
|
|
744
|
+
- name: Start test server
|
|
745
|
+
run: |
|
|
746
|
+
docker-compose up -d
|
|
747
|
+
sleep 10 # Wait for startup
|
|
748
|
+
|
|
749
|
+
- name: Run smoke test
|
|
750
|
+
run: k6 run load-tests/smoke.js
|
|
751
|
+
|
|
752
|
+
- name: Run load test
|
|
753
|
+
run: k6 run --out json=results.json load-tests/load.js
|
|
754
|
+
|
|
755
|
+
- name: Upload results
|
|
756
|
+
uses: actions/upload-artifact@v3
|
|
757
|
+
with:
|
|
758
|
+
name: load-test-results
|
|
759
|
+
path: results.json
|
|
760
|
+
|
|
761
|
+
- name: Stop test server
|
|
762
|
+
run: docker-compose down
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
### Performance Budget
|
|
766
|
+
|
|
767
|
+
```javascript
|
|
768
|
+
// load-tests/budget.js
|
|
769
|
+
import http from 'k6/http';
|
|
770
|
+
import { check } from 'k6';
|
|
771
|
+
|
|
772
|
+
// Performance budget
|
|
773
|
+
const BUDGET = {
|
|
774
|
+
requests: {
|
|
775
|
+
count: 100000, // Max 100k requests
|
|
776
|
+
errorRate: 0.01, // Max 1% errors
|
|
777
|
+
},
|
|
778
|
+
timing: {
|
|
779
|
+
median: 100, // Median < 100ms
|
|
780
|
+
p95: 500, // 95th < 500ms
|
|
781
|
+
p99: 1000, // 99th < 1s
|
|
782
|
+
},
|
|
783
|
+
data: {
|
|
784
|
+
download: 1000000, // Max 1MB download per request
|
|
785
|
+
},
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
export const options = {
|
|
789
|
+
vus: 100,
|
|
790
|
+
duration: '5m',
|
|
791
|
+
thresholds: {
|
|
792
|
+
http_req_duration: [
|
|
793
|
+
`med<${BUDGET.timing.median}`,
|
|
794
|
+
`p(95)<${BUDGET.timing.p95}`,
|
|
795
|
+
`p(99)<${BUDGET.timing.p99}`,
|
|
796
|
+
],
|
|
797
|
+
http_req_failed: [`rate<${BUDGET.requests.errorRate}`],
|
|
798
|
+
data_received: [`avg<${BUDGET.data.download}`],
|
|
799
|
+
},
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
export default function () {
|
|
803
|
+
const res = http.get('http://localhost:3000/users.list');
|
|
804
|
+
|
|
805
|
+
check(res, {
|
|
806
|
+
'response time within budget': (r) => r.timings.duration < BUDGET.timing.p95,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
---
|
|
812
|
+
|
|
813
|
+
## Troubleshooting Performance Issues
|
|
814
|
+
|
|
815
|
+
### Common Issues
|
|
816
|
+
|
|
817
|
+
**1. High Response Times**
|
|
818
|
+
|
|
819
|
+
```javascript
|
|
820
|
+
// Debug slow requests
|
|
821
|
+
import http from 'k6/http';
|
|
822
|
+
import { check } from 'k6';
|
|
823
|
+
import { Counter } from 'k6/metrics';
|
|
824
|
+
|
|
825
|
+
const slowRequests = new Counter('slow_requests');
|
|
826
|
+
|
|
827
|
+
export default function () {
|
|
828
|
+
const start = Date.now();
|
|
829
|
+
const res = http.get('http://localhost:3000/users.list');
|
|
830
|
+
const duration = Date.now() - start;
|
|
831
|
+
|
|
832
|
+
if (duration > 1000) {
|
|
833
|
+
slowRequests.add(1);
|
|
834
|
+
console.log(`Slow request: ${duration}ms - ${res.url}`);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
check(res, {
|
|
838
|
+
'status 200': (r) => r.status === 200,
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
**2. Memory Leaks**
|
|
844
|
+
|
|
845
|
+
```javascript
|
|
846
|
+
// Monitor memory over time
|
|
847
|
+
import exec from 'k6/execution';
|
|
848
|
+
|
|
849
|
+
export const options = {
|
|
850
|
+
vus: 50,
|
|
851
|
+
duration: '30m', // Long test to detect leaks
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
export function setup() {
|
|
855
|
+
return { startTime: Date.now() };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
export default function (data) {
|
|
859
|
+
// Run normal test
|
|
860
|
+
http.get('http://localhost:3000/users.list');
|
|
861
|
+
|
|
862
|
+
// Log progress
|
|
863
|
+
if (__ITER % 1000 === 0) {
|
|
864
|
+
const elapsed = (Date.now() - data.startTime) / 1000 / 60;
|
|
865
|
+
console.log(`Running for ${elapsed.toFixed(1)} minutes, iteration ${__ITER}`);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
**3. Database Connection Issues**
|
|
871
|
+
|
|
872
|
+
```javascript
|
|
873
|
+
// Test connection pool exhaustion
|
|
874
|
+
export const options = {
|
|
875
|
+
vus: 200, // High concurrency to test pool
|
|
876
|
+
duration: '2m',
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
export default function () {
|
|
880
|
+
// Rapid sequential requests
|
|
881
|
+
for (let i = 0; i < 10; i++) {
|
|
882
|
+
const res = http.get('http://localhost:3000/users.list');
|
|
883
|
+
|
|
884
|
+
check(res, {
|
|
885
|
+
'no connection errors': (r) => r.status !== 503,
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
### Analysis Tools
|
|
892
|
+
|
|
893
|
+
```bash
|
|
894
|
+
# Generate HTML report
|
|
895
|
+
k6 run --out html=report.html load-tests/load.js
|
|
896
|
+
|
|
897
|
+
# Export to InfluxDB for Grafana
|
|
898
|
+
k6 run --out influxdb=http://localhost:8086/k6 load-tests/load.js
|
|
899
|
+
|
|
900
|
+
# Export to Prometheus
|
|
901
|
+
k6 run --out experimental-prometheus-rw load-tests/load.js
|
|
902
|
+
|
|
903
|
+
# Compare runs
|
|
904
|
+
k6 compare run1.json run2.json
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
### Interpreting Results
|
|
908
|
+
|
|
909
|
+
**Good Performance:**
|
|
910
|
+
```
|
|
911
|
+
http_req_duration..............: avg=45ms min=10ms med=40ms max=150ms p(90)=80ms p(95)=100ms
|
|
912
|
+
http_req_failed................: 0.00%
|
|
913
|
+
http_reqs......................: 50000 1000/s
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
**Performance Issues:**
|
|
917
|
+
```
|
|
918
|
+
http_req_duration..............: avg=500ms min=50ms med=450ms max=3000ms p(90)=1200ms p(95)=2000ms
|
|
919
|
+
http_req_failed................: 5.00%
|
|
920
|
+
http_reqs......................: 5000 100/s ← Low throughput
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
**Actions for Bad Performance:**
|
|
924
|
+
1. Check database connection pool
|
|
925
|
+
2. Review slow query logs
|
|
926
|
+
3. Check for N+1 queries
|
|
927
|
+
4. Verify caching is working
|
|
928
|
+
5. Monitor server resources (CPU, memory)
|
|
929
|
+
6. Check for blocking operations
|
|
930
|
+
|
|
931
|
+
---
|
|
932
|
+
|
|
933
|
+
## Quick Reference
|
|
934
|
+
|
|
935
|
+
### Run Commands
|
|
936
|
+
|
|
937
|
+
```bash
|
|
938
|
+
# Basic test
|
|
939
|
+
k6 run load-tests/smoke.js
|
|
940
|
+
|
|
941
|
+
# With environment variables
|
|
942
|
+
k6 run -e BASE_URL=https://api.example.com load-tests/load.js
|
|
943
|
+
|
|
944
|
+
# Cloud execution
|
|
945
|
+
k6 cloud run load-tests/load.js
|
|
946
|
+
|
|
947
|
+
# With custom options
|
|
948
|
+
k6 run --vus 100 --duration 5m load-tests/load.js
|
|
949
|
+
|
|
950
|
+
# Verbose output
|
|
951
|
+
k6 run --verbose load-tests/smoke.js
|
|
952
|
+
```
|
|
953
|
+
|
|
954
|
+
### Key Metrics
|
|
955
|
+
|
|
956
|
+
| Metric | Good | Warning | Bad |
|
|
957
|
+
|--------|------|---------|-----|
|
|
958
|
+
| http_req_duration (p95) | < 200ms | 200-500ms | > 500ms |
|
|
959
|
+
| http_req_failed | < 0.1% | 0.1-1% | > 1% |
|
|
960
|
+
| http_reqs (throughput) | > 1000/s | 100-1000/s | < 100/s |
|
|
961
|
+
| vus | Scalable | Limited by resources | System overloaded |
|
|
962
|
+
|
|
963
|
+
### Testing Checklist
|
|
964
|
+
|
|
965
|
+
Before production:
|
|
966
|
+
- [ ] Smoke test passes
|
|
967
|
+
- [ ] Load test meets performance budget
|
|
968
|
+
- [ ] Stress test identifies breaking point
|
|
969
|
+
- [ ] Spike test handles sudden traffic
|
|
970
|
+
- [ ] Soak test stable for 4+ hours
|
|
971
|
+
- [ ] All error rates < 1%
|
|
972
|
+
- [ ] Database handles concurrent connections
|
|
973
|
+
- [ ] Memory usage stable over time
|
|
974
|
+
- [ ] Cache hit rates acceptable
|