tenseleyflow/shithub / bd63968

Browse files

S39: k6 load-test scenarios — mixed-read/auth-mix/issue-comment-storm/search-load

Authored by espadonne
SHA
bd6396808d4836242da4a2c7a5865f9dd1675666
Parents
248b4bf
Tree
ba3c2f6

6 changed files

StatusFile+-
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
+}