chore(load): start implementation of load tests
initial test from Ed's nuc:
metric pctl baseline current delta status
-----------------------------------------------------------------------------------------------
folder_cascade.list_depth1 p50 0.3ms 0.3ms -6.3% ok
folder_cascade.list_depth1 p95 2.3ms 0.5ms -75.7% ok
folder_cascade.list_depth1 p99 4.7ms 2.5ms -48.1% ok
folder_cascade.list_depth4 p50 0.4ms 0.3ms -10.0% ok
folder_cascade.list_depth4 p95 0.9ms 0.6ms -31.2% ok
folder_cascade.list_depth4 p99 2.4ms 1.0ms -56.7% ok
folder_cascade.list_depth8 p50 0.3ms 0.3ms -8.8% ok
folder_cascade.list_depth8 p95 0.6ms 0.5ms -22.2% ok
folder_cascade.list_depth8 p99 1.9ms 0.5ms -71.5% ok
folder_cascade.list_depth_deep p50 0.3ms 0.3ms -5.0% ok
folder_cascade.list_depth_deep p95 0.6ms 0.5ms -18.6% ok
folder_cascade.list_depth_deep p99 2.0ms 0.7ms -67.2% ok
share_cascade_rebac.list_grants p50 0.4ms 0.3ms -27.6% ok
share_cascade_rebac.list_grants p95 1.2ms 0.5ms -57.0% ok
share_cascade_rebac.list_grants p99 1.7ms 1.1ms -36.7% ok
share_cascade_rebac.fetch_as_grantee_depth1 p50 0.5ms 0.5ms -11.1% ok
share_cascade_rebac.fetch_as_grantee_depth1 p95 1.1ms 0.7ms -41.2% ok
share_cascade_rebac.fetch_as_grantee_depth1 p99 3.0ms 1.3ms -58.3% ok
share_cascade_rebac.fetch_as_grantee_depth4 p50 0.5ms 0.5ms -13.7% ok
share_cascade_rebac.fetch_as_grantee_depth4 p95 1.4ms 0.7ms -50.5% ok
share_cascade_rebac.fetch_as_grantee_depth4 p99 2.2ms 1.1ms -49.3% ok
share_cascade_rebac.fetch_as_grantee_depth8 p50 0.5ms 0.4ms -16.0% ok
share_cascade_rebac.fetch_as_grantee_depth8 p95 0.9ms 0.7ms -26.1% ok
share_cascade_rebac.fetch_as_grantee_depth8 p99 1.5ms 0.9ms -40.1% ok
share_cascade_rebac.fetch_as_grantee_depth_deep p50 0.5ms 0.4ms -17.3% ok
share_cascade_rebac.fetch_as_grantee_depth_deep p95 1.0ms 0.7ms -31.2% ok
share_cascade_rebac.fetch_as_grantee_depth_deep p99 1.6ms 0.8ms -49.4% ok
subject_group_nested.fetch_as_member_depth1 p50 0.5ms 0.4ms -8.4% ok
subject_group_nested.fetch_as_member_depth1 p95 0.6ms 0.6ms -10.2% ok
subject_group_nested.fetch_as_member_depth1 p99 1.4ms 0.6ms -55.2% ok
subject_group_nested.fetch_as_member_depth4 p50 0.5ms 0.5ms -7.9% ok
subject_group_nested.fetch_as_member_depth4 p95 0.6ms 0.6ms -4.0% ok
subject_group_nested.fetch_as_member_depth4 p99 0.7ms 0.6ms -2.2% ok
subject_group_nested.fetch_as_member_depth8 p50 0.5ms 0.4ms -8.9% ok
subject_group_nested.fetch_as_member_depth8 p95 0.5ms 0.6ms +7.1% ok
subject_group_nested.fetch_as_member_depth8 p99 0.6ms 0.7ms +10.3% ok
subject_group_nested.fetch_as_member_depth_deep p50 0.5ms 0.4ms -7.6% ok
subject_group_nested.fetch_as_member_depth_deep p95 0.6ms 0.5ms -11.9% ok
subject_group_nested.fetch_as_member_depth_deep p99 0.6ms 0.7ms +8.9% ok
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
// Login helper for OxiCloud k6 load tests.
|
||||
// Returns the JWT access token from POST /api/auth/login.
|
||||
|
||||
import { check, fail } from 'k6';
|
||||
import http from 'k6/http';
|
||||
import { BASE } from './http.js';
|
||||
|
||||
/**
|
||||
* Log in via the public auth endpoint and return the bearer token.
|
||||
*
|
||||
* `body` shape matches `auth_handler.rs::login` — `{username, password}`.
|
||||
* The response carries `access_token` plus refresh state we don't need here.
|
||||
*
|
||||
* The `op` tag should be scenario-qualified (e.g. 'smoke.login') so the
|
||||
* recorded metric matches the corresponding key in baseline/baseline.json.
|
||||
* Bare 'login' is fine for ad-hoc scripts that aren't regression-gated.
|
||||
*
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
* @param {string} [op='login']
|
||||
* @returns {string}
|
||||
*/
|
||||
export function login(username, password, op = 'login') {
|
||||
const res = http.post(
|
||||
`${BASE}/api/auth/login`,
|
||||
JSON.stringify({ username, password }),
|
||||
{ headers: { 'Content-Type': 'application/json' }, tags: { op } },
|
||||
);
|
||||
|
||||
const ok = check(res, {
|
||||
'login 200': (r) => r.status === 200,
|
||||
'login has access_token': (r) => !!r.json('access_token'),
|
||||
});
|
||||
if (!ok) {
|
||||
fail(`login failed for ${username}: status=${res.status}, body=${res.body}`);
|
||||
}
|
||||
return res.json('access_token');
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Shared HTTP helpers for OxiCloud k6 load tests.
|
||||
// Centralises the base URL and adds the Bearer token + JSON Content-Type
|
||||
// headers expected by every protected endpoint.
|
||||
|
||||
import http from 'k6/http';
|
||||
|
||||
/**
|
||||
* Base URL of the OxiCloud server under test.
|
||||
* Read from K6_BASE_URL (set by run.sh), defaulting to the load-suite port.
|
||||
*/
|
||||
export const BASE = __ENV.K6_BASE_URL || 'http://localhost:8088';
|
||||
|
||||
/**
|
||||
* Build a request params object with auth + JSON headers and a `tag` so
|
||||
* the response's metrics are isolated under `<scenario>.<op>`.
|
||||
*
|
||||
* @param {string} token Bearer token from auth.login()
|
||||
* @param {string} op Metric tag, e.g. 'folder_cascade.list_depth8'
|
||||
*/
|
||||
export function jsonParams(token, op) {
|
||||
return {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
tags: { op },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as jsonParams but without a body content-type — for GETs and DELETEs.
|
||||
*/
|
||||
export function authParams(token, op) {
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
tags: { op },
|
||||
};
|
||||
}
|
||||
|
||||
export const httpClient = http;
|
||||
@@ -0,0 +1,76 @@
|
||||
// Baseline-driven thresholds and shared helpers for k6 scenarios.
|
||||
//
|
||||
// Each scenario tags its requests with `op: '<scenario>.<op>'` (see http.js
|
||||
// `jsonParams` / `authParams`). The baseline file lists one entry per
|
||||
// `<scenario>.<op>` with p50/p95/p99 and a tolerance percentage; we convert
|
||||
// every relevant entry to a k6 threshold so the run fails directly on
|
||||
// regression — `compare.mjs` then produces the human-readable diff.
|
||||
|
||||
// Resolve relative to THIS file (lib/metrics.js), not the importer. Future
|
||||
// k6 versions will align open()'s path-resolution with ES module semantics;
|
||||
// using import.meta.resolve() future-proofs against the warning logged by
|
||||
// k6 ≥ 0.50.
|
||||
const LOAD_BASELINE_PATH = import.meta.resolve('../baseline/load.json');
|
||||
const SMOKE_BASELINE_PATH = import.meta.resolve('../baseline/smoke.json');
|
||||
const MANIFEST_PATH = import.meta.resolve('../results/seed-manifest.json');
|
||||
|
||||
// Eagerly load both baseline files at module init (open() is only allowed
|
||||
// in init context). Keys are disjoint by scenario prefix, so merging the
|
||||
// two maps is safe; `thresholdsFromBaseline(prefix)` filters from the union.
|
||||
const BASELINE = {
|
||||
...JSON.parse(open(LOAD_BASELINE_PATH)),
|
||||
...JSON.parse(open(SMOKE_BASELINE_PATH)),
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the merged baseline (load + smoke) — convenient for tooling that
|
||||
* wants to inspect everything; scenarios should use `thresholdsFromBaseline`.
|
||||
*/
|
||||
export function loadBaseline() {
|
||||
return BASELINE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a k6 `thresholds` object from the baseline files, filtered to the
|
||||
* given scenario prefix (e.g. 'folder_cascade').
|
||||
*
|
||||
* Result shape (k6 expects metric-name → threshold-expression-array):
|
||||
* {
|
||||
* 'http_req_duration{op:folder_cascade.list_depth8}': [
|
||||
* 'p(95)<49.5', // 45 * (1 + 10/100)
|
||||
* 'p(99)<88.0',
|
||||
* ],
|
||||
* }
|
||||
*
|
||||
* @param {string} scenarioPrefix
|
||||
*/
|
||||
export function thresholdsFromBaseline(scenarioPrefix) {
|
||||
const baseline = BASELINE;
|
||||
const thresholds = {};
|
||||
for (const [key, val] of Object.entries(baseline)) {
|
||||
if (key.startsWith('_')) continue; // skip _comment etc.
|
||||
if (!key.startsWith(`${scenarioPrefix}.`)) continue;
|
||||
const tol = (val.tolerance_pct || 10) / 100;
|
||||
// Mirror compare.mjs: use the larger of the %-based limit and the
|
||||
// absolute-floor limit (baseline + min_delta_ms). Below sub-millisecond
|
||||
// scale, the % rule alone fires on noise — the floor stops that.
|
||||
const minDelta = val.min_delta_ms ?? 0.5;
|
||||
const p95Limit = Math.max(val.p95 * (1 + tol), val.p95 + minDelta);
|
||||
const p99Limit = Math.max(val.p99 * (1 + tol), val.p99 + minDelta);
|
||||
thresholds[`http_req_duration{op:${key}}`] = [
|
||||
`p(95)<${p95Limit.toFixed(2)}`,
|
||||
`p(99)<${p99Limit.toFixed(2)}`,
|
||||
];
|
||||
}
|
||||
// `abortOnFail: false` (default) keeps the run going so we collect all
|
||||
// regressions in one pass; the non-zero exit at the end still fails CI.
|
||||
return thresholds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the seed manifest written by `cargo run --bin load-seed`.
|
||||
*/
|
||||
export function loadManifest() {
|
||||
const raw = open(MANIFEST_PATH);
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
Reference in New Issue
Block a user