S39: k6 load-test scenarios — mixed-read/auth-mix/issue-comment-storm/search-load
- SHA
bd6396808d4836242da4a2c7a5865f9dd1675666- Parents
-
248b4bf - Tree
ba3c2f6
bd63968
bd6396808d4836242da4a2c7a5865f9dd1675666248b4bf
ba3c2f6| Status | File | + | - |
|---|---|---|---|
| A |
tests/load/README.md
|
76 | 0 |
| A |
tests/load/k6/scenarios/auth-mix.js
|
58 | 0 |
| A |
tests/load/k6/scenarios/issue-comment-storm.js
|
59 | 0 |
| A |
tests/load/k6/scenarios/mixed-read.js
|
54 | 0 |
| A |
tests/load/k6/scenarios/search-load.js
|
48 | 0 |
| A |
tests/load/k6/thresholds.json
|
7 | 0 |
tests/load/README.mdadded@@ -0,0 +1,76 @@ | ||
| 1 | +# Load tests | |
| 2 | + | |
| 3 | +k6 scenarios used in the S39 hardening sprint. Each script is a | |
| 4 | +single load shape; combine them by running concurrently from a | |
| 5 | +load-generator host (the staging instance must NOT receive load | |
| 6 | +from the production droplet). | |
| 7 | + | |
| 8 | +## Scenarios | |
| 9 | + | |
| 10 | +| Script | Shape | | |
| 11 | +|-------------------------------------|-----------------------------------------------------------------------| | |
| 12 | +| `scenarios/mixed-read.js` | 100 RPS anonymous browsing of public repos for 10 min | | |
| 13 | +| `scenarios/auth-mix.js` | 50 RPS authenticated API + browsing for 10 min | | |
| 14 | +| `scenarios/issue-comment-storm.js` | 100 comments/sec across 50 issues for 5 min | | |
| 15 | +| `scenarios/search-load.js` | 30 RPS to search endpoints for 10 min | | |
| 16 | + | |
| 17 | +Two scenarios from the S39 spec are not yet shipped here: | |
| 18 | + | |
| 19 | +- **Push storm** — needs a Git client harness, not k6's HTTP runner. | |
| 20 | + Track in a follow-up. | |
| 21 | +- **SSH connection burst** — needs a real SSH client; the SSH | |
| 22 | + transport itself isn't shipped (see | |
| 23 | + `docs/public/user/ssh.md`). When SSH lands, add an SSH-specific | |
| 24 | + scenario. | |
| 25 | + | |
| 26 | +## Running locally against the dev server | |
| 27 | + | |
| 28 | +```sh | |
| 29 | +make dev-db dev-storage dev-migrate dev | |
| 30 | +# in another terminal: | |
| 31 | +make load-test BENCH_TARGET=http://127.0.0.1:8080 | |
| 32 | +``` | |
| 33 | + | |
| 34 | +`make load-test` runs the mixed-read scenario by default; tune | |
| 35 | +with `K6_SCENARIO=auth-mix make load-test` etc. | |
| 36 | + | |
| 37 | +## Running against staging | |
| 38 | + | |
| 39 | +```sh | |
| 40 | +export BASE=https://staging.shithub.example | |
| 41 | +export TOKEN=shp_<a-pat-on-a-test-account> | |
| 42 | +export REPO=loadtest/issue-storm # for the comment-storm scenario | |
| 43 | +export FIRST_ISSUE=1 | |
| 44 | + | |
| 45 | +k6 run -e BASE -e TOKEN tests/load/k6/scenarios/auth-mix.js | |
| 46 | +k6 run -e BASE tests/load/k6/scenarios/mixed-read.js | |
| 47 | +k6 run -e BASE -e TOKEN -e REPO -e FIRST_ISSUE tests/load/k6/scenarios/issue-comment-storm.js | |
| 48 | +k6 run -e BASE tests/load/k6/scenarios/search-load.js | |
| 49 | +``` | |
| 50 | + | |
| 51 | +## Thresholds | |
| 52 | + | |
| 53 | +`thresholds.json` is shared across scenarios. Default thresholds: | |
| 54 | + | |
| 55 | +- HTTP failure rate < 1% (excluding rate-limit 429s, which k6 | |
| 56 | + doesn't classify as failures). | |
| 57 | +- p95 < 3000 ms. | |
| 58 | +- p99 < 5000 ms. | |
| 59 | +- Per-check pass rate > 99%. | |
| 60 | + | |
| 61 | +These come from the S39 spec: "p95 < 2x of S36's single-user | |
| 62 | +bench numbers under sustained load." Tighten them once | |
| 63 | +production has run for a quarter. | |
| 64 | + | |
| 65 | +## What good looks like | |
| 66 | + | |
| 67 | +After a clean run, capture the JSON output: | |
| 68 | + | |
| 69 | +```sh | |
| 70 | +k6 run --out json=results.json tests/load/k6/scenarios/mixed-read.js | |
| 71 | +``` | |
| 72 | + | |
| 73 | +Summarise per-scenario p50/p95/p99 + total RPS into the capacity | |
| 74 | +record (`docs/internal/capacity.md`). The numbers there are | |
| 75 | +"S39 baseline at MVP launch"; subsequent hardening sprints diff | |
| 76 | +against them to catch regressions. | |
tests/load/k6/scenarios/auth-mix.jsadded@@ -0,0 +1,58 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | +// | |
| 3 | +// k6 scenario: 50 RPS of authenticated API + browsing. | |
| 4 | +// Each VU presents a PAT (Bearer token) on /api/v1 calls and | |
| 5 | +// rotates between API reads, dashboard renders, and notification | |
| 6 | +// inbox views. | |
| 7 | +// | |
| 8 | +// Required env: | |
| 9 | +// BASE — staging URL | |
| 10 | +// TOKEN — a PAT for a test user with `user:read` + `repo:read` | |
| 11 | +// | |
| 12 | +// Run: | |
| 13 | +// k6 run -e BASE=https://staging.shithub.example -e TOKEN=shp_... \ | |
| 14 | +// tests/load/k6/scenarios/auth-mix.js | |
| 15 | + | |
| 16 | +import http from "k6/http"; | |
| 17 | +import { check } from "k6"; | |
| 18 | + | |
| 19 | +const BASE = __ENV.BASE || "http://127.0.0.1:8080"; | |
| 20 | +const TOKEN = __ENV.TOKEN; | |
| 21 | +if (!TOKEN) { | |
| 22 | + throw new Error("TOKEN env var required (a valid shp_ PAT)"); | |
| 23 | +} | |
| 24 | + | |
| 25 | +export const options = { | |
| 26 | + thresholds: JSON.parse(open("../thresholds.json")), | |
| 27 | + scenarios: { | |
| 28 | + auth_mix: { | |
| 29 | + executor: "constant-arrival-rate", | |
| 30 | + rate: 50, | |
| 31 | + timeUnit: "1s", | |
| 32 | + duration: __ENV.DURATION || "10m", | |
| 33 | + preAllocatedVUs: 25, | |
| 34 | + maxVUs: 100, | |
| 35 | + }, | |
| 36 | + }, | |
| 37 | +}; | |
| 38 | + | |
| 39 | +const apiHeaders = { | |
| 40 | + "Authorization": `Bearer ${TOKEN}`, | |
| 41 | + "Accept": "application/json", | |
| 42 | +}; | |
| 43 | + | |
| 44 | +const ACTIONS = [ | |
| 45 | + () => http.get(`${BASE}/api/v1/user`, { headers: apiHeaders, tags: { kind: "api-user" } }), | |
| 46 | + () => http.get(`${BASE}/api/v1/user/starred`, { headers: apiHeaders, tags: { kind: "api-stars" } }), | |
| 47 | + () => http.get(`${BASE}/`, { tags: { kind: "ui-home" } }), | |
| 48 | + () => http.get(`${BASE}/notifications`, { tags: { kind: "ui-notifications" } }), | |
| 49 | +]; | |
| 50 | + | |
| 51 | +export default function () { | |
| 52 | + const action = ACTIONS[Math.floor(Math.random() * ACTIONS.length)]; | |
| 53 | + const res = action(); | |
| 54 | + check(res, { | |
| 55 | + "no 5xx": (r) => r.status < 500, | |
| 56 | + "no auth 401": (r) => r.status !== 401, | |
| 57 | + }); | |
| 58 | +} | |
tests/load/k6/scenarios/issue-comment-storm.jsadded@@ -0,0 +1,59 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | +// | |
| 3 | +// k6 scenario: 100 comments/sec across 50 issues. Stresses the | |
| 4 | +// notification fan-out path — every comment triggers email + inbox | |
| 5 | +// + websocket fan-out via the worker queue. | |
| 6 | +// | |
| 7 | +// Verifies that: | |
| 8 | +// - Comment POSTs return 200 (or 429 if rate-limited). | |
| 9 | +// - Worker queue stays bounded under sustained load. | |
| 10 | +// - DB connection pool doesn't exhaust. | |
| 11 | +// | |
| 12 | +// Required env: | |
| 13 | +// BASE — staging URL | |
| 14 | +// TOKEN — a PAT for a test user with `repo` write to all 50 issues | |
| 15 | +// REPO — `owner/name` of the test repo | |
| 16 | +// FIRST_ISSUE — int, the first issue number (script writes to FIRST..FIRST+49) | |
| 17 | +// | |
| 18 | +// NOTE: this scenario currently uses the issue web form (HTML) since | |
| 19 | +// the issues API is not yet shipped (see docs/public/api/issues.md). | |
| 20 | +// When the API lands, switch to it. | |
| 21 | + | |
| 22 | +import http from "k6/http"; | |
| 23 | +import { check } from "k6"; | |
| 24 | + | |
| 25 | +const BASE = __ENV.BASE || "http://127.0.0.1:8080"; | |
| 26 | +const REPO = __ENV.REPO || "alice/loadtest"; | |
| 27 | +const FIRST_ISSUE = parseInt(__ENV.FIRST_ISSUE || "1", 10); | |
| 28 | +const TOKEN = __ENV.TOKEN; | |
| 29 | + | |
| 30 | +export const options = { | |
| 31 | + thresholds: JSON.parse(open("../thresholds.json")), | |
| 32 | + scenarios: { | |
| 33 | + comment_storm: { | |
| 34 | + executor: "constant-arrival-rate", | |
| 35 | + rate: 100, | |
| 36 | + timeUnit: "1s", | |
| 37 | + duration: __ENV.DURATION || "5m", | |
| 38 | + preAllocatedVUs: 50, | |
| 39 | + maxVUs: 200, | |
| 40 | + }, | |
| 41 | + }, | |
| 42 | +}; | |
| 43 | + | |
| 44 | +export default function () { | |
| 45 | + const issueNum = FIRST_ISSUE + Math.floor(Math.random() * 50); | |
| 46 | + const url = `${BASE}/${REPO}/issues/${issueNum}/comments`; | |
| 47 | + const body = { | |
| 48 | + body: `Load-test comment at ${Date.now()} (vu ${__VU} iter ${__ITER})`, | |
| 49 | + }; | |
| 50 | + const headers = TOKEN | |
| 51 | + ? { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" } | |
| 52 | + : { "Content-Type": "application/json" }; | |
| 53 | + | |
| 54 | + const res = http.post(url, JSON.stringify(body), { headers, tags: { kind: "issue-comment" } }); | |
| 55 | + check(res, { | |
| 56 | + "no 5xx": (r) => r.status < 500, | |
| 57 | + "200 or 429 expected": (r) => r.status === 200 || r.status === 201 || r.status === 429, | |
| 58 | + }); | |
| 59 | +} | |
tests/load/k6/scenarios/mixed-read.jsadded@@ -0,0 +1,54 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | +// | |
| 3 | +// k6 scenario: anonymous browsing of public repos at 100 RPS | |
| 4 | +// sustained for 10 minutes. This is the highest-volume traffic | |
| 5 | +// shape we expect at MVP launch — the home page, /explore, and | |
| 6 | +// repo overview pages dominate the read mix. | |
| 7 | +// | |
| 8 | +// Run: | |
| 9 | +// k6 run --vus 50 --duration 10m \ | |
| 10 | +// -e BASE=https://staging.shithub.example \ | |
| 11 | +// tests/load/k6/scenarios/mixed-read.js | |
| 12 | +// | |
| 13 | +// `vus` is intentionally tunable: 50 VUs at ~50 ms/req averages | |
| 14 | +// to ~1000 RPS; the scenario is throttled by think-time so the | |
| 15 | +// effective rate lands near 100 RPS unless the staging instance | |
| 16 | +// is faster than expected. | |
| 17 | + | |
| 18 | +import http from "k6/http"; | |
| 19 | +import { check, sleep } from "k6"; | |
| 20 | + | |
| 21 | +const BASE = __ENV.BASE || "http://127.0.0.1:8080"; | |
| 22 | + | |
| 23 | +export const options = { | |
| 24 | + thresholds: JSON.parse(open("../thresholds.json")), | |
| 25 | + scenarios: { | |
| 26 | + anonymous_browse: { | |
| 27 | + executor: "constant-arrival-rate", | |
| 28 | + rate: 100, // requests per timeUnit | |
| 29 | + timeUnit: "1s", | |
| 30 | + duration: __ENV.DURATION || "10m", | |
| 31 | + preAllocatedVUs: 50, | |
| 32 | + maxVUs: 200, | |
| 33 | + }, | |
| 34 | + }, | |
| 35 | +}; | |
| 36 | + | |
| 37 | +const PATHS = [ | |
| 38 | + "/", | |
| 39 | + "/explore", | |
| 40 | + "/explore?sort=stars", | |
| 41 | + "/-/health", | |
| 42 | + // Plug in seeded-fixture repo paths if your staging has them: | |
| 43 | + // "/alice/example-repo", | |
| 44 | + // "/alice/example-repo/tree/main", | |
| 45 | +]; | |
| 46 | + | |
| 47 | +export default function () { | |
| 48 | + const path = PATHS[Math.floor(Math.random() * PATHS.length)]; | |
| 49 | + const res = http.get(`${BASE}${path}`, { tags: { route: path } }); | |
| 50 | + check(res, { | |
| 51 | + "status 2xx or 304": (r) => r.status === 200 || r.status === 304, | |
| 52 | + "no 5xx": (r) => r.status < 500, | |
| 53 | + }); | |
| 54 | +} | |
tests/load/k6/scenarios/search-load.jsadded@@ -0,0 +1,48 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | +// | |
| 3 | +// k6 scenario: 30 RPS to search endpoints with realistic query | |
| 4 | +// distribution. Search is one of the more DB-heavy paths; this | |
| 5 | +// scenario surfaces N+1 regressions and index-coverage gaps. | |
| 6 | +// | |
| 7 | +// Run: | |
| 8 | +// k6 run -e BASE=https://staging.shithub.example \ | |
| 9 | +// tests/load/k6/scenarios/search-load.js | |
| 10 | + | |
| 11 | +import http from "k6/http"; | |
| 12 | +import { check } from "k6"; | |
| 13 | + | |
| 14 | +const BASE = __ENV.BASE || "http://127.0.0.1:8080"; | |
| 15 | + | |
| 16 | +export const options = { | |
| 17 | + thresholds: JSON.parse(open("../thresholds.json")), | |
| 18 | + scenarios: { | |
| 19 | + search: { | |
| 20 | + executor: "constant-arrival-rate", | |
| 21 | + rate: 30, | |
| 22 | + timeUnit: "1s", | |
| 23 | + duration: __ENV.DURATION || "10m", | |
| 24 | + preAllocatedVUs: 20, | |
| 25 | + maxVUs: 80, | |
| 26 | + }, | |
| 27 | + }, | |
| 28 | +}; | |
| 29 | + | |
| 30 | +// Realistic distribution: short common terms dominate, with a long | |
| 31 | +// tail of more specific phrases. | |
| 32 | +const QUERIES = [ | |
| 33 | + "func", "main", "test", "config", "import", | |
| 34 | + "TODO", "FIXME", "context.Context", "http.Handler", | |
| 35 | + "policy.Can", "render.New", "go-chi", "golang", | |
| 36 | + "argon2id", "session_key", "WAL archive", | |
| 37 | +]; | |
| 38 | +const SCOPES = ["", "type:repo", "type:user", "type:issue"]; | |
| 39 | + | |
| 40 | +export default function () { | |
| 41 | + const q = QUERIES[Math.floor(Math.random() * QUERIES.length)]; | |
| 42 | + const scope = SCOPES[Math.floor(Math.random() * SCOPES.length)]; | |
| 43 | + const path = `/search?q=${encodeURIComponent(q + (scope ? " " + scope : ""))}`; | |
| 44 | + const res = http.get(`${BASE}${path}`, { tags: { kind: "search" } }); | |
| 45 | + check(res, { | |
| 46 | + "no 5xx": (r) => r.status < 500, | |
| 47 | + }); | |
| 48 | +} | |
tests/load/k6/thresholds.jsonadded@@ -0,0 +1,7 @@ | ||
| 1 | +{ | |
| 2 | + "_comment": "Shared k6 thresholds. Each scenario imports this so failure modes are consistent across the load-test suite. Numbers come from S36's bench harness baselines doubled, per the S39 spec ('p95 < 2x of S36's single-user bench numbers under sustained load').", | |
| 3 | + "http_req_failed": ["rate<0.01"], | |
| 4 | + "http_req_duration": ["p(95)<3000", "p(99)<5000"], | |
| 5 | + "http_reqs": ["count>1000"], | |
| 6 | + "checks": ["rate>0.99"] | |
| 7 | +} | |