Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -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.
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user