Skip to content

Commit 31edb71

Browse files
chore: Setup k6 load testing artifacts based on ADR-007 (#194)
Creates the initial scaffolding, seed script, and `workflow-crud` scenario to run k6 load tests as described by the architecture decision record. This includes: - Adding `load-tests/` directory structure and `k6` typings - Generating `seed.ts` script to generate testing topology connected to the database. - Writing k6 configuration JSON and helper functions for test runs. - Defining an initial `workflow-crud` scenario testing full API end-to-end load path. - Adding npm scripts `load-test:seed`, `load-test:smoke`, and `load-test:baseline` to `package.json`. Co-authored-by: giovannibaratta <11740915+giovannibaratta@users.noreply.github.com>
1 parent e3aa247 commit 31edb71

18 files changed

Lines changed: 1018 additions & 4 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,6 @@ TODO
5656

5757
manual-tests
5858
.archive
59+
load-tests/seed-data.json
60+
load-tests/report.html
61+
load-tests/summary.json

eslint.config.mjs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1+
import {fileURLToPath} from "node:url"
2+
import path from "node:path"
13
import eslint from "@eslint/js"
24
import tseslint from "typescript-eslint"
35
import jestPlugin from "eslint-plugin-jest"
46
import prettierPlugin from "eslint-plugin-prettier/recommended"
57
import nPlugin from "eslint-plugin-n"
68
import globals from "globals"
79

10+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
11+
812
export default tseslint.config(
913
{
10-
ignores: ["build/**", "generated/**", "dist/**", ".yarn/**"]
14+
ignores: ["build/**", "generated/**", "dist/**", ".yarn/**", "load-tests/**", "coverage/**"]
1115
},
1216
eslint.configs.recommended,
1317
nPlugin.configs["flat/recommended"],
@@ -20,7 +24,10 @@ export default tseslint.config(
2024
},
2125
// https://typescript-eslint.io/getting-started/typed-linting/
2226
parserOptions: {
23-
projectService: true
27+
tsconfigRootDir: __dirname,
28+
projectService: {
29+
allowDefaultProject: ["eslint.config.mjs", "webpack.config.js", "jest.config.js"]
30+
}
2431
}
2532
},
2633
rules: {
@@ -105,5 +112,15 @@ export default tseslint.config(
105112
"@typescript-eslint/no-unsafe-return": "off",
106113
"@typescript-eslint/no-unsafe-argument": "off"
107114
}
115+
},
116+
{
117+
languageOptions: {
118+
parserOptions: {
119+
tsconfigRootDir: __dirname,
120+
projectService: {
121+
allowDefaultProject: ["eslint.config.mjs", "webpack.config.js", "jest.config.js"]
122+
}
123+
}
124+
}
108125
}
109126
)

load-tests/README.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Approvio Load Tests
2+
3+
This directory contains the load testing suite for Approvio, based on the strategy defined in [ADR-007 Load Testing Strategy](../../docs/ADR/007-load-testing-strategy.md). It uses [k6](https://k6.io/) as the load generation tool.
4+
5+
## Architecture
6+
7+
The load testing framework is split into two phases:
8+
9+
1. **Seed Phase (`scripts/seed.ts`)**: A Node.js script that connects directly to the database via Prisma and the authentication system to pre-provision a realistic organizational topology (Orgs, Spaces, Groups, Templates, Users, Agents, and auth tokens). It outputs this topology to a `seed-data.json` file.
10+
2. **Execution Phase (`scripts/scenarios/*.ts`)**: k6 scripts that simulate Virtual Users (VUs) interacting with the system. These scripts consume the `seed-data.json` file to know which endpoints to hit, which tokens to use, and which IDs to reference.
11+
12+
## Directory Structure
13+
14+
- `scripts/`: Entrypoints for test execution.
15+
- `seed.ts`: Setup script to generate test data.
16+
- `scenarios/`: Individual k6 test scripts.
17+
- `workflow-crud.ts`: Tests the core create-vote-check flow.
18+
- `lib/`: Shared helper modules for k6 scripts.
19+
- `auth.ts`: Manages picking random pre-generated tokens.
20+
- `data-gen.ts`: Generates synthetic payloads (unique workflow names, etc).
21+
- `checks.ts`: Custom k6 response validation.
22+
- `config/`: JSON files defining k6 execution profiles (VUs, duration, arrival rate).
23+
24+
## Prerequisites
25+
26+
- [k6](https://k6.io/docs/get-started/installation/) must be installed locally.
27+
- The Approvio backend API must be running.
28+
- The Approvio PostgreSQL database must be accessible for the seed script.
29+
30+
## Running Tests
31+
32+
First, generate the seed data:
33+
34+
```bash
35+
yarn load-test:seed
36+
```
37+
38+
Then, run a test profile using the generic runner:
39+
40+
```bash
41+
yarn load-test <config_name> <scenario_name>
42+
```
43+
44+
For convenience, shortcuts for standard profiles are defined:
45+
46+
```bash
47+
yarn load-test:smoke # equivalent to: yarn load-test smoke workflow-crud
48+
yarn load-test:baseline # equivalent to: yarn load-test baseline workflow-crud
49+
yarn load-test:stress # equivalent to: yarn load-test stress workflow-crud
50+
```
51+
52+
## Profiles
53+
54+
- **Smoke (`smoke.json`)**: Used to quickly verify that scripts, seed data, and target endpoints are functioning correctly.
55+
- **Baseline (`baseline.json`)**: Sustained load to establishes reference latency/throughput baselines.
56+
- **Stress (`stress.json`)**: Ramps up throughput over 10 minutes (up to 200 max VUs). Used to find the system's breaking point and resource limits.
57+
58+
## Configuration Concepts
59+
60+
k6 profiles are defined under `config/*.json` and configure how the test scenario generates load. Key concepts include:
61+
62+
### 1. Executors
63+
An executor is the engine that drives the k6 run. We use different executors depending on the test profile:
64+
- **`constant-vus`** (used in Smoke): A fixed number of Virtual Users run as many iterations as they can for a specified duration. This is a *closed-loop* model where load generation depends on how fast the system under test responds.
65+
- **`constant-arrival-rate`** (used in Baseline): k6 starts a fixed number of iterations (`rate`) per time unit (e.g., `1s`), dynamically scaling the number of VUs up to `maxVUs` as needed to maintain that rate. This is an *open-loop* model, which prevents **coordinated omission** (where slow responses from a struggling system artificially lower the load generator's request rate).
66+
- **`ramping-arrival-rate`** (used in Stress): Iterations are started at a changing rate that ramps up or down through defined stages.
67+
68+
### 2. Virtual Users (VUs)
69+
VUs are concurrent, independent execution loops.
70+
- In closed-loop executors (`constant-vus`), the VU count is fixed.
71+
- In open-loop executors (`constant-arrival-rate`/`ramping-arrival-rate`), VUs are allocated dynamically up to `maxVUs` to sustain the target iteration start rate. We pre-allocate a starting number (`preAllocatedVUs`) to avoid initialization overhead during the test.
72+
73+
### 3. Rate & TimeUnit
74+
Used by arrival-rate executors to specify the target throughput:
75+
- **`rate`**: Number of iterations to start per time unit.
76+
- **`timeUnit`**: The period for the target rate (typically `"1s"`).
77+
78+
### 4. Thresholds
79+
Pass/fail criteria evaluated on metrics. For example:
80+
- `http_req_failed`: The percentage of failed requests must remain under a threshold (e.g., `"rate<0.01"` for < 1% errors).
81+
- `http_req_duration`: Latency percentiles (e.g., `"p(95)<1000"` meaning 95% of requests must complete in under 1000ms).
82+
83+
## Reading and Interpreting Results
84+
85+
At the end of a test run, k6 prints a summary report in the console and saves detailed outputs to `load-tests/report.html` and `load-tests/summary.json`. Here is how to understand and interpret key metrics:
86+
87+
### 1. Iterations vs. HTTP Requests
88+
- **`iterations`**: Represents the execution of the main scenario function (`export default function() { ... }`). In our case, one iteration simulates a complete user journey (e.g., creating a workflow, fetching its status, and submitting a vote).
89+
- **`iters/s` (or iteration rate)**: The number of completed user scenarios per second.
90+
- **`http_reqs` (and request rate)**: The actual HTTP request rate (RPS). Because each iteration contains 3 HTTP requests (POST `/workflows` + GET `/workflows/{id}` + POST `/workflows/{id}/vote`), your **RPS is roughly `3 * iters/s`** (excluding retries/pre-flight checks).
91+
92+
### 2. Identifying Performance Bottlenecks
93+
94+
Look for these key indicators in the report to evaluate system health:
95+
96+
- **`dropped_iterations`**: If this number is greater than `0`, it means k6 tried to start new iterations to meet the target arrival rate (e.g., 20 iters/s) but was blocked because all VUs up to `maxVUs` were busy. This is a primary indicator that the backend is processing requests too slowly and has saturated the allocated VU pool.
97+
- **`http_req_duration`**:
98+
- `med (median/p50)`: The middle response time (under typical conditions).
99+
- `p(95)` and `p(99)`: Tail latencies representing the worst-case 5% and 1% of requests. Spikes here indicate database locks, Node.js event-loop blocking, or queue delays.
100+
- **`http_req_failed`**: The rate of HTTP requests that returned error status codes (5xx). Note that expected non-5xx errors under load (such as 409 OCC conflicts or 429 rate limits) are tracked separately from failed requests.
101+
- **`http_req_waiting`**: Time spent waiting for the first byte of response (often server-side processing/database query execution time). If this dominates `http_req_duration`, the bottleneck is server-side CPU or database CPU/locking, not network transport.
102+
103+

load-tests/config/baseline.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"scenarios": {
3+
"baseline": {
4+
"executor": "constant-arrival-rate",
5+
"rate": 20,
6+
"timeUnit": "1s",
7+
"duration": "3m",
8+
"preAllocatedVUs": 10,
9+
"maxVUs": 50
10+
}
11+
},
12+
"thresholds": {
13+
"http_req_failed": ["rate<0.01"],
14+
"http_req_duration": ["p(95)<1000"]
15+
}
16+
}

load-tests/config/smoke.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"scenarios": {
3+
"smoke": {
4+
"executor": "constant-vus",
5+
"vus": 1,
6+
"duration": "30s"
7+
}
8+
},
9+
"thresholds": {
10+
"http_req_failed": ["rate<0.01"],
11+
"http_req_duration": ["p(95)<1000"]
12+
}
13+
}

load-tests/config/stress.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"scenarios": {
3+
"stress": {
4+
"executor": "ramping-arrival-rate",
5+
"startRate": 10,
6+
"timeUnit": "1s",
7+
"preAllocatedVUs": 20,
8+
"maxVUs": 200,
9+
"stages": [
10+
{ "target": 50, "duration": "2m" },
11+
{ "target": 100, "duration": "3m" },
12+
{ "target": 200, "duration": "3m" },
13+
{ "target": 10, "duration": "2m" }
14+
]
15+
}
16+
},
17+
"thresholds": {
18+
"http_req_failed": ["rate<0.05"],
19+
"http_req_duration": ["p(95)<2000"]
20+
}
21+
}

load-tests/lib/auth.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import {SharedArray} from "k6/data"
2+
3+
export interface SeedUser {
4+
id: string
5+
token: string
6+
}
7+
8+
export interface TemplateVoter {
9+
token: string
10+
groupId: string
11+
}
12+
13+
export interface SeedTemplate {
14+
id: string
15+
name: string
16+
voters: TemplateVoter[]
17+
}
18+
19+
export interface SeedData {
20+
users: SeedUser[]
21+
adminToken: string
22+
groups: string[]
23+
spaces: string[]
24+
templates: SeedTemplate[]
25+
}
26+
27+
const seedData = new SharedArray<SeedData>("seed data", () => {
28+
// Read the seed data file generated by seed.ts
29+
// In k6, reading files must be done using the global JSON.parse and open() functions
30+
// within the init context.
31+
const f = JSON.parse(open("../seed-data.json")) as SeedData
32+
33+
// Validate the data structure before returning
34+
if (!f || typeof f !== "object") throw new Error("Invalid seed data: root object is missing or invalid")
35+
if (!Array.isArray(f.users)) throw new Error("Invalid seed data: users is missing or not an array")
36+
if (typeof f.adminToken !== "string") throw new Error("Invalid seed data: adminToken is missing or not a string")
37+
if (!Array.isArray(f.groups)) throw new Error("Invalid seed data: groups is missing or not an array")
38+
if (!Array.isArray(f.spaces)) throw new Error("Invalid seed data: spaces is missing or not an array")
39+
if (!Array.isArray(f.templates)) throw new Error("Invalid seed data: templates is missing or not an array")
40+
41+
return [f] // SharedArray requires an array return
42+
})
43+
44+
export function getRandomUserToken(): string {
45+
const data = seedData[0]
46+
if (!data) throw new Error("Seed data is not initialized")
47+
if (!data.users || data.users.length === 0) throw new Error("No users found in seed data")
48+
49+
const user = data.users[Math.floor(Math.random() * data.users.length)]
50+
if (!user) throw new Error("No user selected")
51+
return user.token
52+
}
53+
54+
export function getAdminToken(): string {
55+
const data = seedData[0]
56+
if (!data) throw new Error("Seed data is not initialized")
57+
return data.adminToken
58+
}
59+
60+
export function getRandomTemplate(): SeedTemplate {
61+
const data = seedData[0]
62+
if (!data) throw new Error("Seed data is not initialized")
63+
if (!data.templates || data.templates.length === 0) throw new Error("No templates found in seed data")
64+
65+
const template = data.templates[Math.floor(Math.random() * data.templates.length)]
66+
if (!template) throw new Error("No template selected")
67+
return template
68+
}
69+
70+
export function getRandomUser(): SeedUser {
71+
const data = seedData[0]
72+
if (!data) throw new Error("Seed data is not initialized")
73+
if (!data.users || data.users.length === 0) throw new Error("No users found in seed data")
74+
75+
const user = data.users[Math.floor(Math.random() * data.users.length)]
76+
if (!user) throw new Error("No user selected")
77+
return user
78+
}
79+
80+
export function getRandomVoterForTemplate(template: SeedTemplate): TemplateVoter {
81+
if (!template.voters || template.voters.length === 0) {
82+
throw new Error(`No voters found for template ${template.id}`)
83+
}
84+
const voter = template.voters[Math.floor(Math.random() * template.voters.length)]
85+
if (!voter) throw new Error("No voter selected")
86+
return voter
87+
}

load-tests/lib/checks.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {check} from "k6"
2+
import {Response} from "k6/http"
3+
4+
export function is201(res: Response, endpoint: string) {
5+
return check(res, {
6+
[`${endpoint} status is 201`]: r => r.status === 201
7+
})
8+
}
9+
10+
export function is200(res: Response, endpoint: string) {
11+
return check(res, {
12+
[`${endpoint} status is 200`]: r => r.status === 200
13+
})
14+
}
15+
16+
// Helper to check if a response is successful or a known expected error under load
17+
// (e.g., 409 Conflict due to OCC, 429 Rate Limit)
18+
export function isSuccessOrExpectedError(res: Response, endpoint: string) {
19+
return check(res, {
20+
[`${endpoint} status is 2xx, 409, or 429`]: r =>
21+
(r.status >= 200 && r.status < 300) || r.status === 409 || r.status === 429
22+
})
23+
}
24+
25+
export function extractIdFromLocation(res: Response) {
26+
if (res.status === 201 && res.headers) {
27+
// Location header might be lowercase in HTTP/2, check both
28+
const location = res.headers.Location || res.headers.location
29+
if (location && typeof location === "string") {
30+
const parts = location.split("/")
31+
return parts[parts.length - 1]
32+
}
33+
}
34+
return null
35+
}
36+
37+

load-tests/lib/data-gen.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import exec from "k6/execution"
2+
3+
// Helper to generate a unique string per VU and iteration
4+
// Useful for creating unique names that don't violate DB constraints
5+
export function generateUniqueName(prefix: string) {
6+
const vuId = exec.vu.idInTest
7+
const iter = exec.scenario.iterationInTest
8+
const ts = new Date().getTime()
9+
return `${prefix}-${vuId}-${iter}-${ts}`
10+
}
11+
12+
export function generateWorkflowPayload(templateId: string) {
13+
return {
14+
name: generateUniqueName("load-wf"),
15+
description: "Generated by k6 load test",
16+
workflowTemplateId: templateId
17+
}
18+
}
19+
20+
export function generateVotePayload(groupId: string) {
21+
return {
22+
voteType: {
23+
type: "APPROVE",
24+
votedForGroups: [groupId]
25+
}
26+
}
27+
}

load-tests/lib/k6-reporters.d.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Type declarations for remote HTTPS imports used in k6 scripts.
3+
*
4+
* k6 runs scripts directly inside its own JavaScript engine, resolving HTTPS URL imports
5+
* at runtime. However, the TypeScript compiler (tsc) and IDEs do not natively know how
6+
* to type check remote HTTPS modules, resulting in compiler error ts(2307).
7+
*
8+
* These ambient module declarations map wildcards for remote CDNs/repositories
9+
* to untyped (or loosely typed) modules, allowing VS Code and eslint to validate
10+
* import statements and code without flagging HTTPS imports as missing modules.
11+
*/
12+
13+
declare module "https://raw.githubusercontent.com/benc-uk/k6-reporter/*" {
14+
export function htmlReport(data: any, options?: any): string;
15+
}
16+
17+
declare module "https://jslib.k6.io/k6-summary/*" {
18+
export function textSummary(data: any, options?: any): string;
19+
}

0 commit comments

Comments
 (0)