Skip to content

Commit 37256d2

Browse files
committed
Load tests
1 parent fb1b793 commit 37256d2

File tree

15 files changed

+1102
-0
lines changed

15 files changed

+1102
-0
lines changed

backend/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,6 @@ tmp/
5858
*~
5959
\#*\#
6060
.\#*
61+
62+
# Load testing
63+
cmd/loadtest/config.json

backend/Makefile

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,33 @@ jaeger-logs: ## View Jaeger logs
9595

9696
jaeger-ps: ## Show Jaeger container status
9797
@cd ../docker/jaeger && docker compose ps
98+
99+
## Load Testing Commands
100+
101+
loadtest-gen: ## Generate JWT tokens for load testing (run after seed-large)
102+
@echo "Generating load test tokens..."
103+
@go run ./cmd/loadtest/tokengen -limit 1000 -output ./cmd/loadtest/config.json
104+
105+
loadtest-gen-all: ## Generate tokens for all seeded users (up to 10k)
106+
@echo "Generating load test tokens for all users..."
107+
@go run ./cmd/loadtest/tokengen -limit 10000 -output ./cmd/loadtest/config.json
108+
109+
loadtest: loadtest-gen ## Run default load test (50 VUs, 5 minutes)
110+
@echo "Running load test..."
111+
@k6 run ./cmd/loadtest/k6/steady.js
112+
113+
loadtest-quick: loadtest-gen ## Run quick load test (10 VUs, 1 minute)
114+
@echo "Running quick load test..."
115+
@k6 run --env STEADY_VUS=10 --env DURATION=1m ./cmd/loadtest/k6/steady.js
116+
117+
loadtest-stress: loadtest-gen-all ## Run stress test (200 VUs, 10 minutes)
118+
@echo "Running stress test..."
119+
@k6 run --env STEADY_VUS=200 --env DURATION=10m ./cmd/loadtest/k6/steady.js
120+
121+
loadtest-spike: loadtest-gen ## Run spike test only (500 VUs)
122+
@echo "Running spike test..."
123+
@k6 run --env SPIKE_PEAK=500 --env SPIKE_HOLD=2m ./cmd/loadtest/k6/spike.js
124+
125+
loadtest-leaderboard: loadtest-gen ## Run leaderboard stress test only (100 RPS)
126+
@echo "Running leaderboard stress test..."
127+
@k6 run --env LEADERBOARD_RPS=100 --env LEADERBOARD_DURATION=5m ./cmd/loadtest/k6/leaderboard.js

backend/cmd/loadtest/README.md

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Load Testing
2+
3+
k6-based load testing for the Wayfarer GraphQL API.
4+
5+
## Prerequisites
6+
7+
1. Install k6:
8+
```bash
9+
brew install k6 # macOS
10+
# or see https://k6.io/docs/getting-started/installation/
11+
```
12+
13+
2. Seed the database with test users:
14+
```bash
15+
make seed-large # Creates 10k users
16+
```
17+
18+
3. Start the server:
19+
```bash
20+
make dev
21+
```
22+
23+
## Quick Start
24+
25+
```bash
26+
# Run the default load test (50 VUs, 5 minutes)
27+
make loadtest
28+
29+
# Run a quick sanity check (10 VUs, 1 minute)
30+
make loadtest-quick
31+
```
32+
33+
## Available Commands
34+
35+
| Command | Description |
36+
|---------|-------------|
37+
| `make loadtest` | Default load test: 50 VUs for 5 minutes |
38+
| `make loadtest-quick` | Quick test: 10 VUs for 1 minute |
39+
| `make loadtest-stress` | Stress test: 200 VUs for 10 minutes |
40+
| `make loadtest-spike` | Spike test only: ramp 0 -> 500 -> 0 VUs |
41+
| `make loadtest-leaderboard` | Leaderboard stress: 100 requests/second |
42+
| `make loadtest-gen` | Generate tokens only (1000 users) |
43+
| `make loadtest-gen-all` | Generate tokens for all users (up to 10k) |
44+
45+
## Test Scenarios
46+
47+
### 1. Steady Load (`steady_load`)
48+
Simulates typical user traffic with weighted distribution:
49+
- 30% ChallengesPage
50+
- 20% ProfilePage
51+
- 20% StandingsGlobalPage
52+
- 15% StandingsLocalPage
53+
- 15% StandingsUnitPage
54+
55+
### 2. Spike Test (`spike_test`)
56+
Simulates sudden traffic surges:
57+
- Ramps from 0 to 100 VUs in 30s
58+
- Peaks at 500 VUs for 1 minute
59+
- Ramps down to 0 over 1 minute
60+
61+
### 3. Leaderboard Stress (`leaderboard_stress`)
62+
Focused testing of database-intensive leaderboard queries:
63+
- Constant 100 requests/second
64+
- 50% global standings, 30% local, 20% team
65+
66+
## Configuration
67+
68+
### Environment Variables
69+
70+
| Variable | Default | Description |
71+
|----------|---------|-------------|
72+
| `STEADY_VUS` | 50 | Virtual users for steady-state test |
73+
| `DURATION` | 5m | Duration of steady-state test |
74+
| `SPIKE_START` | 6m | When spike test starts |
75+
| `SPIKE_PEAK` | 500 | Peak VUs during spike |
76+
| `LEADERBOARD_START` | 0s | When leaderboard test starts |
77+
| `LEADERBOARD_DURATION` | 5m | Duration of leaderboard test |
78+
| `LEADERBOARD_RPS` | 100 | Requests per second |
79+
80+
### Token Generator Flags
81+
82+
```bash
83+
go run ./cmd/loadtest/tokengen \
84+
-limit 1000 \ # Number of users
85+
-output config.json \ # Output file
86+
-base-url http://localhost:8080 \ # API URL
87+
-valid-days 7 \ # Token validity
88+
-secret "your-jwt-secret" # Override JWT_SECRET
89+
```
90+
91+
## Thresholds
92+
93+
The test will fail if:
94+
- p95 response time > 500ms
95+
- p99 response time > 1000ms
96+
- Error rate > 1%
97+
- GraphQL error rate > 1%
98+
99+
## Custom Test Runs
100+
101+
```bash
102+
# Run with custom parameters
103+
k6 run --env STEADY_VUS=100 --env DURATION=10m ./cmd/loadtest/k6/scenarios.js
104+
105+
# Run against a different target
106+
go run ./cmd/loadtest/tokengen -base-url https://staging.api.example.com -output ./cmd/loadtest/config.json
107+
k6 run ./cmd/loadtest/k6/scenarios.js
108+
109+
# Output JSON results
110+
k6 run --out json=results.json ./cmd/loadtest/k6/scenarios.js
111+
```
112+
113+
## Output Metrics
114+
115+
Key metrics to watch:
116+
- `http_req_duration` - Request latency (p50, p95, p99)
117+
- `http_req_failed` - Failed request percentage
118+
- `graphql_errors` - GraphQL-specific errors
119+
- `http_reqs` - Total requests per second
120+
- `vus` - Active virtual users
121+
122+
## Files
123+
124+
```
125+
cmd/loadtest/
126+
tokengen/
127+
main.go # Go tool to generate JWT tokens
128+
k6/
129+
scenarios.js # Main test script
130+
lib/
131+
graphql.js # GraphQL HTTP helpers
132+
queries/
133+
challenges.js
134+
profile.js
135+
standings-global.js
136+
standings-local.js
137+
standings-unit.js
138+
challenge.js
139+
config.json # Generated tokens (gitignored)
140+
README.md
141+
```
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { SharedArray } from 'k6/data';
2+
3+
import { standingsGlobalPage } from './queries/standings-global.js';
4+
import { standingsLocalPage } from './queries/standings-local.js';
5+
import { standingsUnitPage } from './queries/standings-unit.js';
6+
7+
const config = JSON.parse(open('../config.json'));
8+
const tokens = new SharedArray('tokens', function () {
9+
return config.tokens;
10+
});
11+
const baseUrl = config.baseUrl;
12+
13+
// Leaderboard stress: constant arrival rate
14+
export const options = {
15+
scenarios: {
16+
leaderboard_stress: {
17+
executor: 'constant-arrival-rate',
18+
rate: parseInt(__ENV.LEADERBOARD_RPS) || 100,
19+
timeUnit: '1s',
20+
duration: __ENV.LEADERBOARD_DURATION || '5m',
21+
preAllocatedVUs: parseInt(__ENV.LEADERBOARD_RPS) || 100,
22+
maxVUs: 1000,
23+
},
24+
},
25+
thresholds: {
26+
http_req_duration: ['p(95)<500', 'p(99)<1000'],
27+
http_req_failed: ['rate<0.01'],
28+
graphql_errors: ['rate<0.01'],
29+
},
30+
};
31+
32+
function getRandomToken() {
33+
return tokens[Math.floor(Math.random() * tokens.length)];
34+
}
35+
36+
export default function () {
37+
const { token } = getRandomToken();
38+
const rand = Math.random();
39+
40+
if (rand < 0.50) {
41+
standingsGlobalPage(baseUrl, token);
42+
} else if (rand < 0.80) {
43+
standingsLocalPage(baseUrl, token);
44+
} else {
45+
standingsUnitPage(baseUrl, token);
46+
}
47+
}
48+
49+
export function setup() {
50+
console.log(`Leaderboard stress: ${tokens.length} users, target ${baseUrl}`);
51+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import http from 'k6/http';
2+
import { check } from 'k6';
3+
import { Counter, Trend } from 'k6/metrics';
4+
5+
// Custom metrics for GraphQL-specific tracking
6+
export const graphqlErrors = new Counter('graphql_errors');
7+
export const graphqlDuration = new Trend('graphql_duration');
8+
9+
/**
10+
* Execute a GraphQL request
11+
* @param {string} baseUrl - Base URL of the GraphQL API
12+
* @param {string} query - GraphQL query string
13+
* @param {object} variables - Query variables
14+
* @param {string} token - JWT token for authorization
15+
* @param {string} operationName - Optional operation name for tagging
16+
* @returns {object} HTTP response
17+
*/
18+
export function graphqlRequest(baseUrl, query, variables, token, operationName) {
19+
const url = `${baseUrl}/graphql`;
20+
const payload = JSON.stringify({
21+
query: query,
22+
variables: variables || {},
23+
});
24+
25+
const params = {
26+
headers: {
27+
'Content-Type': 'application/json',
28+
'Authorization': `Bearer ${token}`,
29+
},
30+
tags: {
31+
name: operationName || 'GraphQL',
32+
},
33+
};
34+
35+
const startTime = Date.now();
36+
const response = http.post(url, payload, params);
37+
graphqlDuration.add(Date.now() - startTime, { operation: operationName });
38+
39+
return response;
40+
}
41+
42+
/**
43+
* Check if a GraphQL response is successful
44+
* @param {object} response - HTTP response object
45+
* @param {string} queryName - Name of the query for logging
46+
* @returns {boolean} Whether the response was successful
47+
*/
48+
export function checkGraphQLResponse(response, queryName) {
49+
const checks = {
50+
[`${queryName}: status is 200`]: (r) => r.status === 200,
51+
[`${queryName}: has data`]: (r) => {
52+
try {
53+
const body = JSON.parse(r.body);
54+
return body.data !== undefined && body.data !== null;
55+
} catch {
56+
return false;
57+
}
58+
},
59+
[`${queryName}: no errors`]: (r) => {
60+
try {
61+
const body = JSON.parse(r.body);
62+
return !body.errors || body.errors.length === 0;
63+
} catch {
64+
return false;
65+
}
66+
},
67+
};
68+
69+
const success = check(response, checks);
70+
71+
if (!success) {
72+
graphqlErrors.add(1, { operation: queryName });
73+
74+
// Log error details for debugging
75+
if (response.status !== 200) {
76+
console.error(`${queryName}: HTTP ${response.status}`);
77+
} else {
78+
try {
79+
const body = JSON.parse(response.body);
80+
if (body.errors) {
81+
console.error(`${queryName}: ${JSON.stringify(body.errors)}`);
82+
}
83+
} catch {
84+
console.error(`${queryName}: Invalid JSON response`);
85+
}
86+
}
87+
}
88+
89+
return success;
90+
}
91+
92+
/**
93+
* Parse GraphQL response body
94+
* @param {object} response - HTTP response object
95+
* @returns {object|null} Parsed response data or null on error
96+
*/
97+
export function parseResponse(response) {
98+
try {
99+
const body = JSON.parse(response.body);
100+
return body.data;
101+
} catch {
102+
return null;
103+
}
104+
}

0 commit comments

Comments
 (0)