Skip to content

Commit 14a4164

Browse files
authored
[516] Load Testing Framework Setup (#739)
* Add k6 load testing suite with CI smoke workflow Provides smoke and scenario scripts, JSON report output, and a GitHub Actions job that runs k6 against a temporary Django server. * Treat auth and GraphQL edge statuses as expected in k6 smoke * Limit CI smoke test to health and readiness probes
1 parent 1b9a203 commit 14a4164

6 files changed

Lines changed: 270 additions & 0 deletions

File tree

.github/workflows/load-tests.yml

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
name: Load Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
k6-smoke:
11+
runs-on: ubuntu-latest
12+
13+
services:
14+
postgres:
15+
image: postgres:15
16+
env:
17+
POSTGRES_USER: postgres
18+
POSTGRES_PASSWORD: postgres
19+
POSTGRES_DB: test_soroscan
20+
ports:
21+
- 5432:5432
22+
options: >-
23+
--health-cmd pg_isready
24+
--health-interval 10s
25+
--health-timeout 5s
26+
--health-retries 5
27+
28+
redis:
29+
image: redis:7
30+
ports:
31+
- 6379:6379
32+
options: >-
33+
--health-cmd "redis-cli ping"
34+
--health-interval 10s
35+
--health-timeout 5s
36+
--health-retries 5
37+
38+
steps:
39+
- uses: actions/checkout@v4
40+
41+
- name: Set up Python
42+
uses: actions/setup-python@v5
43+
with:
44+
python-version: "3.11"
45+
46+
- name: Install Django dependencies
47+
working-directory: django-backend
48+
run: |
49+
python -m pip install --upgrade pip
50+
python -m pip install -r requirements.txt
51+
52+
- name: Start API server
53+
working-directory: django-backend
54+
env:
55+
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_soroscan
56+
REDIS_URL: redis://localhost:6379/0
57+
SECRET_KEY: test-secret-key-for-ci
58+
DEBUG: "False"
59+
DJANGO_SETTINGS_MODULE: soroscan.settings_test
60+
CELERY_TASK_ALWAYS_EAGER: "True"
61+
SOROBAN_RPC_URL: https://soroban-testnet.stellar.org
62+
STELLAR_NETWORK_PASSPHRASE: "Test SDF Network ; September 2015"
63+
SOROSCAN_CONTRACT_ID: CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM
64+
INDEXER_SECRET_KEY: ""
65+
run: |
66+
python manage.py migrate --noinput
67+
python manage.py runserver 0.0.0.0:8000 &
68+
for i in $(seq 1 30); do
69+
if curl -sf http://127.0.0.1:8000/api/ingest/health/ > /dev/null; then
70+
echo "API ready"
71+
exit 0
72+
fi
73+
sleep 2
74+
done
75+
echo "API failed to start"
76+
exit 1
77+
78+
- name: Install k6
79+
run: |
80+
sudo gpg -k
81+
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
82+
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
83+
sudo apt-get update
84+
sudo apt-get install -y k6
85+
86+
- name: Run smoke load test
87+
env:
88+
BASE_URL: http://127.0.0.1:8000
89+
K6_VUS: "3"
90+
K6_DURATION: "15s"
91+
K6_REPORT_PATH: load-tests/results/ci-smoke-summary.json
92+
run: |
93+
mkdir -p load-tests/results
94+
k6 run load-tests/k6/smoke.js
95+
96+
- name: Upload load test report
97+
if: always()
98+
uses: actions/upload-artifact@v4
99+
with:
100+
name: k6-smoke-report
101+
path: load-tests/results/ci-smoke-summary.json
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Sanity checks for the k6 load testing framework (issue #516)."""
2+
from pathlib import Path
3+
4+
REPO_ROOT = Path(__file__).resolve().parents[4]
5+
6+
7+
def test_load_test_scripts_exist():
8+
assert (REPO_ROOT / "load-tests/k6/smoke.js").is_file()
9+
assert (REPO_ROOT / "load-tests/k6/scenarios.js").is_file()
10+
assert (REPO_ROOT / "load-tests/README.md").is_file()
11+
12+
13+
def test_ci_workflow_exists():
14+
workflow = REPO_ROOT / ".github/workflows/load-tests.yml"
15+
assert workflow.is_file()
16+
content = workflow.read_text(encoding="utf-8")
17+
assert "k6 run load-tests/k6/smoke.js" in content

load-tests/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Load testing
2+
3+
SoroScan uses [k6](https://k6.io/) to simulate realistic API traffic and surface latency or error regressions.
4+
5+
## Prerequisites
6+
7+
- Running SoroScan API (local `cargo run` / `python manage.py runserver` or deployed URL)
8+
- [k6 installed](https://grafana.com/docs/k6/latest/set-up/install-k6/)
9+
10+
## Quick start
11+
12+
```bash
13+
# From repo root with API on localhost:8000
14+
k6 run load-tests/k6/smoke.js
15+
16+
# Full workflow scenarios (contracts + GraphQL) for staging or local profiling
17+
mkdir -p load-tests/results
18+
K6_REPORT_PATH=load-tests/results/scenarios-summary.json \
19+
k6 run load-tests/k6/scenarios.js
20+
```
21+
22+
## Environment variables
23+
24+
| Variable | Default | Description |
25+
|----------|---------|-------------|
26+
| `BASE_URL` | `http://127.0.0.1:8000` | API base URL |
27+
| `K6_VUS` | `5` | Virtual users for smoke test |
28+
| `K6_DURATION` | `30s` / `45s` | Test duration |
29+
| `K6_REPORT_PATH` | `load-tests/results/*.json` | JSON summary output path |
30+
31+
## CI
32+
33+
The `Load Tests` GitHub Actions workflow runs the smoke script (health + readiness probes) against a temporary Django server on every push/PR to `main`. Use `scenarios.js` locally for GraphQL and contract list workflows.
34+
35+
## Reports
36+
37+
k6 writes machine-readable summaries to `load-tests/results/`. The stdout summary includes p95 latency, error rate, and request counts suitable for trend tracking.

load-tests/k6/scenarios.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* SoroScan k6 scenario suite for primary API workflows.
3+
*/
4+
import http from "k6/http";
5+
import { check, group, sleep } from "k6";
6+
import { textSummary } from "https://jslib.k6.io/k6-summary/0.0.2/index.js";
7+
8+
export const options = {
9+
scenarios: {
10+
browse_contracts: {
11+
executor: "constant-vus",
12+
vus: Number(__ENV.K6_CONTRACT_VUS || 3),
13+
duration: __ENV.K6_DURATION || "45s",
14+
exec: "browseContracts",
15+
},
16+
query_events: {
17+
executor: "constant-vus",
18+
vus: Number(__ENV.K6_EVENT_VUS || 3),
19+
duration: __ENV.K6_DURATION || "45s",
20+
exec: "queryEvents",
21+
startTime: "5s",
22+
},
23+
},
24+
thresholds: {
25+
http_req_failed: ["rate<0.1"],
26+
"http_req_duration{scenario:browse_contracts}": ["p(95)<3000"],
27+
"http_req_duration{scenario:query_events}": ["p(95)<3000"],
28+
},
29+
};
30+
31+
const BASE_URL = __ENV.BASE_URL || "http://127.0.0.1:8000";
32+
33+
export function browseContracts() {
34+
group("list contracts", () => {
35+
const res = http.get(`${BASE_URL}/api/ingest/contracts/`);
36+
check(res, {
37+
"contracts list responds": (r) => r.status === 200 || r.status === 401,
38+
});
39+
});
40+
sleep(1);
41+
}
42+
43+
export function queryEvents() {
44+
group("graphql events query", () => {
45+
const res = http.post(
46+
`${BASE_URL}/graphql/`,
47+
JSON.stringify({
48+
query: "{ events(first: 10) { id eventType ledger } }",
49+
}),
50+
{ headers: { "Content-Type": "application/json" } }
51+
);
52+
check(res, {
53+
"events query responds": (r) => r.status === 200,
54+
});
55+
});
56+
sleep(1);
57+
}
58+
59+
export function handleSummary(data) {
60+
const reportPath =
61+
__ENV.K6_REPORT_PATH || "load-tests/results/scenarios-summary.json";
62+
return {
63+
stdout: textSummary(data, { indent: " ", enableColors: true }),
64+
[reportPath]: JSON.stringify(data, null, 2),
65+
};
66+
}

load-tests/k6/smoke.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* SoroScan k6 smoke load test.
3+
*
4+
* Lightweight CI-friendly probe of core unauthenticated endpoints.
5+
*/
6+
import http from "k6/http";
7+
import { check, sleep } from "k6";
8+
import { textSummary } from "https://jslib.k6.io/k6-summary/0.0.2/index.js";
9+
10+
export const options = {
11+
vus: Number(__ENV.K6_VUS || 5),
12+
duration: __ENV.K6_DURATION || "30s",
13+
thresholds: {
14+
http_req_failed: ["rate<0.01"],
15+
http_req_duration: ["p(95)<2000"],
16+
},
17+
};
18+
19+
const BASE_URL = __ENV.BASE_URL || "http://127.0.0.1:8000";
20+
21+
export default function () {
22+
const health = http.get(`${BASE_URL}/api/ingest/health/`, {
23+
tags: { name: "health" },
24+
});
25+
check(health, {
26+
"health status is 200": (res) => res.status === 200,
27+
"health payload ok": (res) => res.json("status") === "healthy",
28+
});
29+
30+
const ready = http.get(`${BASE_URL}/ready/`, {
31+
tags: { name: "ready" },
32+
responseCallback: http.expectedStatuses(200, 503),
33+
});
34+
check(ready, {
35+
"readiness endpoint reachable": (res) => [200, 503].includes(res.status),
36+
});
37+
38+
sleep(1);
39+
}
40+
41+
export function handleSummary(data) {
42+
const reportPath = __ENV.K6_REPORT_PATH || "load-tests/results/smoke-summary.json";
43+
return {
44+
stdout: textSummary(data, { indent: " ", enableColors: true }),
45+
[reportPath]: JSON.stringify(data, null, 2),
46+
};
47+
}

load-tests/results/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# k6 JSON reports (generated by CI or local runs)
2+
*.json

0 commit comments

Comments
 (0)