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,60 @@
|
||||
// folder_cascade.js — measures the read-path of `GET /api/folders/{id}/resources?resource_types=folder`
|
||||
// at four depths against a pre-seeded tree. Captures how listing cost scales
|
||||
// with ltree depth (cf. `idx_folders_lpath` GiST index). Mid-depth samples
|
||||
// (depth4, depth8) fall back to `deepest` when the seeded tree is shallower
|
||||
// than that depth — see load-seed.rs::build_subtree.
|
||||
|
||||
import { check } from 'k6';
|
||||
import http from 'k6/http';
|
||||
import { BASE, authParams } from '../lib/http.js';
|
||||
import { login } from '../lib/auth.js';
|
||||
import { thresholdsFromBaseline, loadManifest } from '../lib/metrics.js';
|
||||
|
||||
const manifest = loadManifest();
|
||||
|
||||
export const options = {
|
||||
vus: 1,
|
||||
// 100 iterations gives p99 statistical meaning: at N=100, p99 = position 99,
|
||||
// representing one bad sample out of a hundred — a real percentile rather
|
||||
// than the worst-of-the-batch. At N=25 (the typical k6 example default),
|
||||
// p99 ≈ max, dominated by single-sample kernel/scheduler noise.
|
||||
iterations: 100,
|
||||
thresholds: thresholdsFromBaseline('folder_cascade'),
|
||||
};
|
||||
|
||||
// One per-VU login. K6 calls setup() once across the whole test, default()
|
||||
// `iterations` times per VU. Logging in inside default() would dominate the
|
||||
// per-iter cost; we hand the token down through the `data` arg.
|
||||
export function setup() {
|
||||
const token = login(manifest.admin.username, manifest.admin.password);
|
||||
return { token };
|
||||
}
|
||||
|
||||
export default function (data) {
|
||||
const { token } = data;
|
||||
const t = manifest.shared_subtree;
|
||||
|
||||
const r1 = http.get(
|
||||
`${BASE}/api/folders/${t.root}/resources?resource_types=folder`,
|
||||
authParams(token, 'folder_cascade.list_depth1'),
|
||||
);
|
||||
check(r1, { 'list depth1 200': (r) => r.status === 200 });
|
||||
|
||||
const r4 = http.get(
|
||||
`${BASE}/api/folders/${t.depth4}/resources?resource_types=folder`,
|
||||
authParams(token, 'folder_cascade.list_depth4'),
|
||||
);
|
||||
check(r4, { 'list depth4 200': (r) => r.status === 200 });
|
||||
|
||||
const r8 = http.get(
|
||||
`${BASE}/api/folders/${t.depth8}/resources?resource_types=folder`,
|
||||
authParams(token, 'folder_cascade.list_depth8'),
|
||||
);
|
||||
check(r8, { 'list depth8 200': (r) => r.status === 200 });
|
||||
|
||||
const rD = http.get(
|
||||
`${BASE}/api/folders/${t.deepest}/resources?resource_types=folder`,
|
||||
authParams(token, 'folder_cascade.list_depth_deep'),
|
||||
);
|
||||
check(rD, { 'list deepest 200': (r) => r.status === 200 });
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// share_cascade_rebac.js — measures ReBAC AuthZ cascade via a direct user
|
||||
// grant. The seeder grants `read` on shared_subtree.root (depth 0) to the
|
||||
// grantee user; this scenario times how long the grantee takes to fetch
|
||||
// folders at varying depths inside that subtree.
|
||||
//
|
||||
// The AuthZ recursive-CTE in `pg_acl_engine` walks up the ancestor chain
|
||||
// from the requested folder until it finds a matching grant (or runs out
|
||||
// of ancestors). The deeper the requested folder, the more ancestors the
|
||||
// CTE has to traverse before hitting the grant on the root:
|
||||
//
|
||||
// fetch_at_grant_root → 0 ancestors walked (grant is right here)
|
||||
// fetch_as_grantee_depth4 → 4 ancestors walked
|
||||
// fetch_as_grantee_depth8 → 8 ancestors walked
|
||||
// fetch_as_grantee_depth_deep → `load_depth` ancestors walked
|
||||
//
|
||||
// A regression in AuthZ cost should show up as the cascade-depth curve
|
||||
// flattening or steepening — that's the value of intermediate samples.
|
||||
|
||||
import { check } from 'k6';
|
||||
import http from 'k6/http';
|
||||
import { BASE, authParams } from '../lib/http.js';
|
||||
import { login } from '../lib/auth.js';
|
||||
import { thresholdsFromBaseline, loadManifest } from '../lib/metrics.js';
|
||||
|
||||
const manifest = loadManifest();
|
||||
|
||||
export const options = {
|
||||
vus: 1,
|
||||
// See folder_cascade.js for the iteration-count rationale: N=100 makes
|
||||
// p99 a real percentile instead of the worst-of-the-batch.
|
||||
iterations: 100,
|
||||
thresholds: thresholdsFromBaseline('share_cascade_rebac'),
|
||||
};
|
||||
|
||||
export function setup() {
|
||||
const adminToken = login(manifest.admin.username, manifest.admin.password);
|
||||
const granteeToken = login(manifest.grantee.username, manifest.grantee.password);
|
||||
return { adminToken, granteeToken };
|
||||
}
|
||||
|
||||
export default function (data) {
|
||||
const { adminToken, granteeToken } = data;
|
||||
const t = manifest.shared_subtree;
|
||||
|
||||
// List grants on the granted folder (admin only).
|
||||
const grantsRes = http.get(
|
||||
`${BASE}/api/grants?resource_type=folder&resource_id=${t.root}`,
|
||||
authParams(adminToken, 'share_cascade_rebac.list_grants'),
|
||||
);
|
||||
check(grantsRes, { 'list grants 200': (r) => r.status === 200 });
|
||||
|
||||
// Baseline: grantee fetches the folder the grant is directly on. AuthZ
|
||||
// finds the matching grant on the first row of the CTE; zero ancestors
|
||||
// walked. This metric measures the constant overhead of an authorized
|
||||
// request — moves to the right of this metric is "cascade cost."
|
||||
const d1 = http.get(
|
||||
`${BASE}/api/folders/${t.root}/resources?resource_types=folder`,
|
||||
authParams(granteeToken, 'share_cascade_rebac.fetch_as_grantee_depth1'),
|
||||
);
|
||||
check(d1, { 'fetch root 200': (r) => r.status === 200 });
|
||||
|
||||
// Grantee fetches a mid-tree folder. AuthZ walks 4 ancestors before
|
||||
// hitting the grant.
|
||||
const d4 = http.get(
|
||||
`${BASE}/api/folders/${t.depth4}/resources?resource_types=folder`,
|
||||
authParams(granteeToken, 'share_cascade_rebac.fetch_as_grantee_depth4'),
|
||||
);
|
||||
check(d4, { 'fetch depth4 200': (r) => r.status === 200 });
|
||||
|
||||
// Grantee fetches a folder 8 levels under the granted root. AuthZ
|
||||
// walks 8 ancestors.
|
||||
const d8 = http.get(
|
||||
`${BASE}/api/folders/${t.depth8}/resources?resource_types=folder`,
|
||||
authParams(granteeToken, 'share_cascade_rebac.fetch_as_grantee_depth8'),
|
||||
);
|
||||
check(d8, { 'fetch depth8 200': (r) => r.status === 200 });
|
||||
|
||||
// Grantee fetches the deepest descendant — full-length cascade.
|
||||
const dD = http.get(
|
||||
`${BASE}/api/folders/${t.deepest}/resources?resource_types=folder`,
|
||||
authParams(granteeToken, 'share_cascade_rebac.fetch_as_grantee_depth_deep'),
|
||||
);
|
||||
check(dD, { 'fetch deepest 200': (r) => r.status === 200 });
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// smoke.js — fast PR-tier check. Verifies the load harness still builds and
|
||||
// the server boots, exercising one happy-path of every HTTP verb the long
|
||||
// scenarios use. No regression gate; run.sh's `smoke.sh` skips compare.mjs.
|
||||
|
||||
import { check, sleep } from 'k6';
|
||||
import http from 'k6/http';
|
||||
import { BASE, jsonParams, authParams } from '../lib/http.js';
|
||||
import { login } from '../lib/auth.js';
|
||||
import { thresholdsFromBaseline } from '../lib/metrics.js';
|
||||
|
||||
export const options = {
|
||||
vus: 1,
|
||||
iterations: 1,
|
||||
thresholds: thresholdsFromBaseline('smoke'),
|
||||
};
|
||||
|
||||
// Admin creds match tests/load/test.env defaults — overridable via env.
|
||||
const USERNAME = __ENV.K6_USERNAME || 'admin';
|
||||
const PASSWORD = __ENV.K6_PASSWORD || 'TestPassword1!';
|
||||
|
||||
export default function () {
|
||||
// 1. login
|
||||
const token = login(USERNAME, PASSWORD, 'smoke.login');
|
||||
|
||||
// 2. create a scratch folder at root
|
||||
const folderName = `smoke_${Date.now()}`;
|
||||
const createRes = http.post(
|
||||
`${BASE}/api/folders`,
|
||||
JSON.stringify({ name: folderName }),
|
||||
jsonParams(token, 'smoke.create_folder'),
|
||||
);
|
||||
check(createRes, { 'create folder 200/201': (r) => r.status === 200 || r.status === 201 });
|
||||
const folderId = createRes.json('id');
|
||||
|
||||
// 3. upload a tiny file via multipart
|
||||
const fileData = http.file('hello\n', 'smoke.txt', 'text/plain');
|
||||
const uploadRes = http.post(
|
||||
`${BASE}/api/files/upload`,
|
||||
{ folder_id: folderId, file: fileData },
|
||||
{ headers: { Authorization: `Bearer ${token}` }, tags: { op: 'smoke.upload_tiny' } },
|
||||
);
|
||||
const uploadOk = check(uploadRes, {
|
||||
'upload 200/201': (r) => r.status === 200 || r.status === 201,
|
||||
});
|
||||
if (!uploadOk) {
|
||||
console.error(
|
||||
`upload failed: status=${uploadRes.status}, body=${uploadRes.body}, headers=${JSON.stringify(uploadRes.headers)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. list root folders for this user
|
||||
const listRes = http.get(`${BASE}/api/folders`, authParams(token, 'smoke.list_root'));
|
||||
check(listRes, { 'list root 200': (r) => r.status === 200 });
|
||||
|
||||
// 5. delete the scratch folder
|
||||
const delRes = http.del(
|
||||
`${BASE}/api/folders/${folderId}`,
|
||||
null,
|
||||
authParams(token, 'smoke.delete_folder'),
|
||||
);
|
||||
check(delRes, { 'delete 200/204': (r) => r.status === 200 || r.status === 204 });
|
||||
|
||||
sleep(0.1);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// subject_group_nested.js — measures the worst-case AuthZ path: a user who
|
||||
// is a direct member of the *innermost* group of a depth-N nested chain, and
|
||||
// the grant is on the *outermost* group. Every AuthZ check has to expand the
|
||||
// chain transitively. Pairs with share_cascade_rebac.js to attribute regressions
|
||||
// to either the resource side (folder cascade) or the subject side (group
|
||||
// expansion).
|
||||
//
|
||||
// Same depth-gradient idea as share_cascade_rebac: measure AuthZ cost at
|
||||
// depth 1 / 4 / 8 / deep so we can see WHERE in the chain a regression
|
||||
// lands. The subject-side expansion (group_member → leaf → mid → root group
|
||||
// → grant) runs once per request regardless of folder depth, but the
|
||||
// resource-side cascade (folder ancestors walked to find the grant) grows
|
||||
// linearly with depth — so depth4/depth8/deep moving together while depth1
|
||||
// stays flat would point at the resource side; all four moving together
|
||||
// would point at the group-expansion path.
|
||||
|
||||
import { check } from 'k6';
|
||||
import http from 'k6/http';
|
||||
import { BASE, authParams } from '../lib/http.js';
|
||||
import { login } from '../lib/auth.js';
|
||||
import { thresholdsFromBaseline, loadManifest } from '../lib/metrics.js';
|
||||
|
||||
const manifest = loadManifest();
|
||||
|
||||
export const options = {
|
||||
vus: 1,
|
||||
// See folder_cascade.js for the iteration-count rationale: N=100 makes
|
||||
// p99 a real percentile instead of the worst-of-the-batch.
|
||||
iterations: 100,
|
||||
thresholds: thresholdsFromBaseline('subject_group_nested'),
|
||||
};
|
||||
|
||||
export function setup() {
|
||||
const memberToken = login(manifest.group_member.username, manifest.group_member.password);
|
||||
return { memberToken };
|
||||
}
|
||||
|
||||
export default function (data) {
|
||||
const { memberToken } = data;
|
||||
const t = manifest.group_subtree;
|
||||
|
||||
// Baseline: group member fetches the folder the grant is directly on.
|
||||
// Subject-side expansion runs (user → leaf → mid → root group → grant);
|
||||
// resource-side walk is zero ancestors. Captures the constant subject-
|
||||
// expansion overhead.
|
||||
const d1 = http.get(
|
||||
`${BASE}/api/folders/${t.root}/resources?resource_types=folder`,
|
||||
authParams(memberToken, 'subject_group_nested.fetch_as_member_depth1'),
|
||||
);
|
||||
check(d1, { 'fetch root 200': (r) => r.status === 200 });
|
||||
|
||||
// Mid-tree: 4 folder ancestors walked + subject expansion.
|
||||
const d4 = http.get(
|
||||
`${BASE}/api/folders/${t.depth4}/resources?resource_types=folder`,
|
||||
authParams(memberToken, 'subject_group_nested.fetch_as_member_depth4'),
|
||||
);
|
||||
check(d4, { 'fetch depth4 200': (r) => r.status === 200 });
|
||||
|
||||
// 8 folder ancestors walked + subject expansion.
|
||||
const d8 = http.get(
|
||||
`${BASE}/api/folders/${t.depth8}/resources?resource_types=folder`,
|
||||
authParams(memberToken, 'subject_group_nested.fetch_as_member_depth8'),
|
||||
);
|
||||
check(d8, { 'fetch depth8 200': (r) => r.status === 200 });
|
||||
|
||||
// Worst case: full-depth folder cascade × full-chain subject expansion.
|
||||
// group_member → leaf group → mid group → root group → grant → folder root → deepest.
|
||||
const dD = http.get(
|
||||
`${BASE}/api/folders/${t.deepest}/resources?resource_types=folder`,
|
||||
authParams(memberToken, 'subject_group_nested.fetch_as_member_depth_deep'),
|
||||
);
|
||||
check(dD, { 'fetch deepest 200': (r) => r.status === 200 });
|
||||
}
|
||||
Reference in New Issue
Block a user