Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
# Load test suite (k6)
This directory contains the production-rehearsal k6 load tests for the
boilerplate. They are the load-tier companion to the unit, integration, and
verifier layers — useful before a release cut, after a perf regression
suspicion, or when sizing a new staging tier.
The suite is **opt-in**. CI does not run it by default because it requires a
deployed target environment, a tenant API key, and a k6 binary on the
runner. `scripts/run-load-tests.mjs` is the safe wrapper: it SKIPs cleanly
when either prerequisite is missing.
## Install k6
k6 is a single Go binary — install it from the upstream packages:
- macOS: `brew install k6`
- Linux: see <https://k6.io/docs/getting-started/installation/>
- Container: `grafana/k6:latest` (mount this repo and run inside)
Verify the install with `k6 version`. The runner script checks the same.
## Scripts
| Script | Profile | Thresholds |
| --- | --- | --- |
| `login.js` | 50 VUs, 30s ramp + 30s hold against `POST /v1/auth/login` | `http_req_failed: rate<0.05`, `http_req_duration: p(99)<800ms`, `http_req_duration{name:login}: p(95)<400ms` |
| `api-read.js` | 100 VUs, 60s constant against `GET /v1/entities/<slug>/rows` | `http_req_duration: p(99)<300ms`, `http_reqs: rate>200/s` |
| `api-mutating.js` | 30 VUs, 30s constant against `POST /v1/entities/<slug>/rows` | `http_req_duration: p(99)<600ms` |
| `scenarios.js` | Composite — runs all three back-to-back | Per-suite thresholds tagged by `name:` |
Each per-script file is also runnable standalone — useful when isolating a
regression to one path.
## Threshold rationale
- `http_req_failed: rate<0.05` on login allows for 401s from the synthetic
account while still catching server-side 5xx storms.
- `http_req_duration: p(99)<800ms` on login covers password hashing + session
creation under realistic contention. Tighten as the auth path matures.
- `http_req_duration{name:login}: p(95)<400ms` keeps the typical login fast;
it is the user-facing budget, not the worst-case.
- `http_req_duration: p(99)<300ms` on the read path matches the
warm-cache-hit budget for the hottest API surface.
- `http_reqs: rate>200/s` is a throughput floor — percentile checks alone
miss regressions that quietly halve RPS under contention.
- `http_req_duration: p(99)<600ms` on the mutating path covers transactional
write + audit + queue enqueue.
If a threshold becomes too tight for a particular environment (e.g. a small
staging tier), override at invocation time with `k6 run --threshold` rather
than editing the script — keep the production budget in source.
## Running locally
```sh
# Single script
K6_TARGET_URL=http://localhost:3000 \
LOAD_TENANT_API_KEY=tnk_load_test_key \
k6 run tests/load/login.js
# Full suite via the safe runner (SKIPs if k6 is missing or target down)
K6_TARGET_URL=http://localhost:3000 \
LOAD_TENANT_API_KEY=tnk_load_test_key \
pnpm run verify:load
```
Required env vars:
- `K6_TARGET_URL` — base URL of the target environment (default
`http://localhost:3000`).
- `LOAD_TENANT_API_KEY` — tenant-scoped API key for the test tenant. The
read/mutating scripts default to a placeholder that will return 401 — fine
for SKIP-rehearsal but not for a real load run.
Optional env vars:
- `LOAD_LOGIN_EMAIL` / `LOAD_LOGIN_PASSWORD` — synthetic login credentials.
- `LOAD_ENTITY_SLUG` — entity slug to target on the read/mutating paths.
## Stress vs. smoke
To scale a smoke run down, pass `--vus 5 --duration 10s` to `k6 run`. To
scale a stress run up, increase the per-script VUs via the k6 CLI rather
than editing the file. The committed VU counts are the production-rehearsal
contract.
## Safety notes
- Never point a load test at production without an explicit human approval.
- Mutating runs append synthetic rows tagged with `load-test-row-*` so they
can be cleaned up by a downstream verifier. Pick a throwaway tenant.
- Load tests are part of the production-readiness ladder, not part of CI
Auto-Mode. Keep `verify:load` out of the default lane until a target
environment is wired up.
+68
View File
@@ -0,0 +1,68 @@
// k6 load-test: tenant-scoped mutating path (`POST /v1/entities/:slug/rows`).
//
// Profile: 30 VUs for 30s. Mutating writes are bounded by database write
// contention, idempotency-key bookkeeping, and audit logging — so the
// concurrency is intentionally lower than the read path. The p(99) latency
// budget covers transactional write + audit + queue enqueue.
//
// Run:
// K6_TARGET_URL=http://localhost:3000 \
// LOAD_TENANT_API_KEY=tnk_live_xxx \
// k6 run tests/load/api-mutating.js
//
// Required env:
// LOAD_TENANT_API_KEY tenant-scoped API key for the test tenant
// Optional env:
// LOAD_ENTITY_SLUG entity slug to write to (default: load-test-entity)
import http from "k6/http";
import { check, group } from "k6";
export const options = {
scenarios: {
api_mutating: {
executor: "constant-vus",
vus: 30,
duration: "30s",
tags: { suite: "api-mutating" },
},
},
thresholds: {
http_req_failed: ["rate<0.02"],
http_req_duration: ["p(99)<600"],
},
discardResponseBodies: true,
};
const TARGET_URL = __ENV.K6_TARGET_URL || "http://localhost:3000";
const TENANT_API_KEY = __ENV.LOAD_TENANT_API_KEY || "tnk_load_test_key";
const ENTITY_SLUG = __ENV.LOAD_ENTITY_SLUG || "load-test-entity";
export default function apiMutatingScenario() {
group("api-mutating", function () {
const payload = JSON.stringify({
data: {
// Deterministic, non-PII synthetic payload — load tests must not seed
// customer-shaped data into the target environment.
label: `load-test-row-${__VU}-${__ITER}`,
value: __ITER,
},
});
const params = {
headers: {
accept: "application/json",
"content-type": "application/json",
authorization: `Bearer ${TENANT_API_KEY}`,
// Idempotency key keeps re-runs from accumulating duplicate rows when
// the target environment supports the standard idempotency contract.
"idempotency-key": `load-${__VU}-${__ITER}`,
},
tags: { name: "api-mutating" },
};
const res = http.post(`${TARGET_URL}/v1/entities/${ENTITY_SLUG}/rows`, payload, params);
check(res, {
"status is 2xx, 401, 404, or 409": (r) =>
(r.status >= 200 && r.status < 300) || r.status === 401 || r.status === 404 || r.status === 409,
});
});
}
+56
View File
@@ -0,0 +1,56 @@
// k6 load-test: tenant-scoped read path (`GET /v1/entities/:slug/rows`).
//
// Profile: 100 VUs for 60s. The read path is the hottest API surface in
// production, so the p(99) latency budget is tight and a minimum throughput
// floor is asserted to catch regressions that pass percentile checks while
// quietly halving RPS (e.g. accidental serialisation under contention).
//
// Run:
// K6_TARGET_URL=http://localhost:3000 \
// LOAD_TENANT_API_KEY=tnk_live_xxx \
// k6 run tests/load/api-read.js
//
// Required env:
// LOAD_TENANT_API_KEY tenant-scoped API key for the test tenant
// Optional env:
// LOAD_ENTITY_SLUG entity slug to read (default: load-test-entity)
import http from "k6/http";
import { check, group } from "k6";
export const options = {
scenarios: {
api_read: {
executor: "constant-vus",
vus: 100,
duration: "60s",
tags: { suite: "api-read" },
},
},
thresholds: {
http_req_failed: ["rate<0.01"],
http_req_duration: ["p(99)<300"],
http_reqs: ["rate>200"],
},
discardResponseBodies: true,
};
const TARGET_URL = __ENV.K6_TARGET_URL || "http://localhost:3000";
const TENANT_API_KEY = __ENV.LOAD_TENANT_API_KEY || "tnk_load_test_key";
const ENTITY_SLUG = __ENV.LOAD_ENTITY_SLUG || "load-test-entity";
export default function apiReadScenario() {
group("api-read", function () {
const params = {
headers: {
accept: "application/json",
authorization: `Bearer ${TENANT_API_KEY}`,
},
tags: { name: "api-read" },
};
const res = http.get(`${TARGET_URL}/v1/entities/${ENTITY_SLUG}/rows`, params);
check(res, {
"status is 200 or 401 or 404": (r) => r.status === 200 || r.status === 401 || r.status === 404,
});
});
}
+60
View File
@@ -0,0 +1,60 @@
// k6 load-test: authentication login flow.
//
// Profile: 50 VUs ramping over 30s, holding briefly to gather a stable
// percentile sample. The login endpoint is the most security-critical write
// path, so the failure-rate threshold is tighter than the read path and the
// p(99) latency budget covers password hashing + session creation.
//
// Run:
// K6_TARGET_URL=http://localhost:3000 k6 run tests/load/login.js
//
// Optional env:
// LOAD_LOGIN_EMAIL test account email (default: load-test@example.com)
// LOAD_LOGIN_PASSWORD test account password (default: load-test-password)
import http from "k6/http";
import { check, group } from "k6";
export const options = {
scenarios: {
login: {
executor: "ramping-vus",
startVUs: 0,
stages: [
{ duration: "30s", target: 50 },
{ duration: "30s", target: 50 },
{ duration: "10s", target: 0 },
],
gracefulRampDown: "10s",
tags: { suite: "login" },
},
},
thresholds: {
http_req_failed: ["rate<0.05"],
http_req_duration: ["p(99)<800"],
"http_req_duration{name:login}": ["p(95)<400"],
},
// Avoid surprising the local server with default 100s connection lifetimes
// when this script is run as part of the composite suite.
noConnectionReuse: false,
discardResponseBodies: true,
};
const TARGET_URL = __ENV.K6_TARGET_URL || "http://localhost:3000";
const EMAIL = __ENV.LOAD_LOGIN_EMAIL || "load-test@example.com";
const PASSWORD = __ENV.LOAD_LOGIN_PASSWORD || "load-test-password";
export default function loginScenario() {
group("login", function () {
const payload = JSON.stringify({ email: EMAIL, password: PASSWORD });
const params = {
headers: { "content-type": "application/json" },
tags: { name: "login" },
};
const res = http.post(`${TARGET_URL}/v1/auth/login`, payload, params);
check(res, {
"status is 200 or 401": (r) => r.status === 200 || r.status === 401,
"has body": (r) => r.body === null || r.body !== undefined,
});
});
}
+71
View File
@@ -0,0 +1,71 @@
// k6 composite suite — runs login, read, and mutating scenarios in one go.
//
// Each scenario re-uses the per-script default function so individual scripts
// remain runnable on their own. Thresholds are unioned: the composite must
// satisfy every per-suite p(99) and failure-rate budget.
//
// Run:
// K6_TARGET_URL=http://localhost:3000 \
// LOAD_TENANT_API_KEY=tnk_live_xxx \
// k6 run tests/load/scenarios.js
//
// The composite is the full-suite production-rehearsal entrypoint invoked by
// `scripts/run-load-tests.mjs`. For ad-hoc stress runs, scale the per-script
// VUs via env vars rather than editing this file (see README.md).
import loginScenario from "./login.js";
import apiReadScenario from "./api-read.js";
import apiMutatingScenario from "./api-mutating.js";
export const options = {
scenarios: {
login: {
executor: "ramping-vus",
exec: "login",
startVUs: 0,
stages: [
{ duration: "30s", target: 50 },
{ duration: "30s", target: 50 },
{ duration: "10s", target: 0 },
],
gracefulRampDown: "10s",
tags: { suite: "login" },
},
api_read: {
executor: "constant-vus",
exec: "apiRead",
vus: 100,
duration: "60s",
startTime: "1m20s",
tags: { suite: "api-read" },
},
api_mutating: {
executor: "constant-vus",
exec: "apiMutating",
vus: 30,
duration: "30s",
startTime: "2m25s",
tags: { suite: "api-mutating" },
},
},
thresholds: {
http_req_failed: ["rate<0.05"],
"http_req_duration{name:login}": ["p(95)<400"],
"http_req_duration{name:api-read}": ["p(99)<300"],
"http_req_duration{name:api-mutating}": ["p(99)<600"],
"http_reqs{name:api-read}": ["rate>200"],
},
discardResponseBodies: true,
};
export function login() {
loginScenario();
}
export function apiRead() {
apiReadScenario();
}
export function apiMutating() {
apiMutatingScenario();
}