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:
Edouard Vanbelle
2026-06-14 16:42:58 +02:00
parent 1d0e4d3c42
commit 0ef4c624c5
26 changed files with 2345 additions and 15 deletions
+38
View File
@@ -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');
}
+40
View File
@@ -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;
+76
View File
@@ -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);
}