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
12 KiB
OxiCloud Load Test Scaffolding (K6) — Scenario A
Context
OxiCloud needs a repeatable way to detect performance regressions as features land. The current tests/load/ directory is empty except for a README asking K6-vs-drill. Scenario A focuses on a single user with many subfolders and cascading systems (folder ops, ReBAC permission groups, nested subject groups) — measuring p50/p95/p99 to identify inflections when new features are added. Scenario B (many concurrent users) is deferred.
Decisions already locked in via conversation:
- K6 (Go-based, JS scripting in goja) over Rust testers — client overhead is dwarfed by server latency for these scenarios; regression deltas only require client consistency, not raw speed.
- Two scenario tiers: a fast smoke for PRs (~1 min, no regression gate — just verifies the harness still runs) and long scenarios for nightly + manual (regression-gated).
- Hybrid seeding: bulk fixtures via direct SQL (
cargo run --bin load-seed); the resources each scenario actively touches go through REST so the measured path is realistic. - Result storage:
baseline/baseline.jsoncommitted in repo (p50/p95/p99 + tolerance per metric); per-run raw JSON kept locally / CI artifact, gitignored. Baseline updates are deliberate PRs. - No PR gating on long suite — nightly on main + manual
workflow_dispatchonly.
Approach
1. Directory layout
tests/load/
README.md
test.env # base_url=http://localhost:8088, admin creds
run.sh # full suite: spawn-db → seed → server → k6 → compare → cleanup
smoke.sh # smoke only: same shape, runs scenarios/smoke.js, no gate
compare.mjs # diffs k6 summary.json vs baseline/baseline.json
lib/
auth.js # login() → bearer token (POST /api/auth/login)
http.js # base URL + auth header helpers
metrics.js # custom k6 Trends, naming convention <scenario>.<op>
scenarios/
smoke.js # 1 VU, 1 iter — login, create folder, upload, list, delete
folder_cascade.js # list/move/copy/trash on depth-8 fanout-5 tree
share_cascade_rebac.js # grant a folder, fetch N descendants as grantee
subject_group_nested.js # nested-group chain (depth 3), grant via group, fetch
baseline/
baseline.json # committed; { "<scenario>.<op>": {p50,p95,p99,tolerance_pct} }
results/
.gitkeep # raw runs, gitignored
Mirrors the tests/api/ shell pattern (run.sh, test.env, separate server port). Uses port 8088 to avoid colliding with api-test on 8087.
2. Rust bulk seeder — src/bin/load-seed.rs
New binary registered in Cargo.toml alongside generate-openapi and migrate-nfc-filenames.
CLI:
cargo run --bin load-seed -- \
--depth 8 --fanout 5 --files-per-leaf 10 \
--extra-users 20 --group-depth 3 --group-fanout 5
Inserts via sqlx (one transaction per phase):
- 1 admin + N extra users into
auth.users(Argon2id with light params m=16384,t=1,p=1; same params assrc/infrastructure/services/password_hasher.rstest path). - One shared 0-byte blob row in
storage.blobswithref_count = total_files. - Folder tree in
storage.folders, inserted level-by-level so thetrg_folders_pathBEFORE-INSERT trigger can computepath/lpathfrom the parent chain. - Files in
storage.files, all referencing the shared blob hash for dedup. - Subject groups in
auth.subject_groups+ membership rows inauth.subject_group_members(XORmember_user_id/member_group_id), forming a nested chainG_root → G_mid → G_leaf → users. - ReBAC grants in
storage.access_grantsfor a known set of test resources (used byshare_cascade_rebac.js).
Reuses existing connection config: reads DATABASE_URL from env, same shape as tests/common/server.env.
Critical gotchas surfaced during exploration:
- Tree-ETag triggers (
folders_bump_tree_etag_*) are STATEMENT-level (per commit4200209d), so bulk inserts fire them once per statement — safe. - ReBAC cascade is computed in the application layer (
pg_acl_engine), not at DB level. Seeder just inserts grant rows; the cascade is the server behavior we want to measure. auth.subject_groups.nameis CITEXT, max 64, RFC-5321 local-part shape — generated names useg_NNNpattern to stay valid.
3. K6 scenarios
All scenarios load baseline.json at startup and set thresholds dynamically from it (http_req_duration{op:x}: p(95)<baseline.x.p95 * (1 + tolerance_pct/100)). This makes K6 itself fail the run on regression, and compare.mjs produces the human-readable diff.
Endpoint contracts (confirmed by exploration):
- Login:
POST /api/auth/loginbody{username, password}→{access_token, ...}. - Folder ops:
POST /api/folders,GET /api/folders/{id}/contents(or/contents/paginatedat high depth),PUT /api/folders/{id}/move,POST /api/batch/folders/copy,DELETE /api/folders/{id}. Handlers insrc/interfaces/api/handlers/folder_handler.rsandbatch_handler.rs. - Grants:
POST /api/grants(subject{type, id|email}, resource{type, id},roleorpermissions[]),GET /api/grants?resource_type=folder&resource_id=…. Handlersrc/interfaces/api/handlers/grant_handler.rs. - Groups:
POST /api/groups(admin),POST /api/groups/{id}/membersbody{user_id}or{group_id}. Handlersrc/interfaces/api/handlers/subject_group_handler.rs. Nesting limit is 8 —--group-depth 3is well inside. - Files: multipart
POST /api/files/uploadwith fieldsfolder_id+file.
Per-scenario metric naming (metrics.js enforces): <scenario>.<op> → e.g. folder_cascade.list_depth8, share_cascade_rebac.fetch_as_grantee, subject_group_nested.grant_via_chain3.
Cold/warm split: first iteration tagged cold (its metrics flow to <scenario>.<op>_cold), rest are warm. Single first iter avoids polluting warm metrics with one-shot connection setup.
4. Runner scripts
run.sh (mirrors tests/api/run.sh shape):
spawn-db.sh(reused fromtests/common/)init-test-schema.sh(reused)cargo run --bin load-seed -- <args>against the test DB- Start
target/<profile>/oxicloudon port 8088 withOXICLOUD_STORAGE_PATH=tests/load/storage wait_for_http http://localhost:8088/readyk6 run --summary-export=results/$(date +%s).json scenarios/folder_cascade.js scenarios/share_cascade_rebac.js scenarios/subject_group_nested.jsnode compare.mjs results/<latest>.json baseline/baseline.json→ exits non-zero on regressiontrap cleanup EXITtears down server + DB
smoke.sh: same shape but only runs scenarios/smoke.js, skips the seeder (smoke creates its own minimal data), skips compare.mjs. Goal is harness liveness, not regression.
5. compare.mjs
Plain Node script (no deps, just fs + process). Reads two JSONs, walks every metric in baseline, computes:
delta_pct = (current - baseline) / baseline * 100
Prints a human table:
folder_cascade.list_depth8.p95: 142ms → 198ms (+39%) REGRESSION (tolerance 10%)
share_cascade_rebac.grant.p95: 60ms → 58ms (-3%) ok
Exit 1 if any metric exceeds tolerance. Exit 0 otherwise. Missing metrics in current = exit 1 (suite drift); new metrics in current = warn only.
6. Baseline format
{
"folder_cascade.list_depth8": { "p50": 12.0, "p95": 45.0, "p99": 80.0, "tolerance_pct": 10 },
"share_cascade_rebac.fetch_as_grantee": { "p50": 18.0, "p95": 55.0, "p99": 100.0, "tolerance_pct": 10 },
...
}
Values are placeholders — first run on a stable machine seeds real numbers. Updated only via the load-baseline recipe (which rewrites baseline.json from the latest run) and committed deliberately as chore(load): accept new baseline for <reason>.
7. Justfile recipes (added to existing justfile)
load: # full suite — nightly + manual
bash tests/load/run.sh
load-smoke: # fast harness check
bash tests/load/smoke.sh
load-baseline: # rerun, overwrite baseline from latest result
bash tests/load/run.sh
cp tests/load/results/$(ls -t tests/load/results/*.json | head -1) tests/load/baseline/baseline.json
load-seed: # standalone seeder for local poking
cargo run --bin load-seed -- --depth 8 --fanout 5 --files-per-leaf 10
Matches existing recipe naming (test-*, front-*, api-test).
8. CI workflows (stubs)
.github/workflows/load-nightly.yml— cron0 3 * * *UTC +workflow_dispatch; runsjust loadonubuntu-latestinitially with a# TODO: replace with self-hosted runner — shared GH runners produce noisy results for regression detectioncomment; uploadstests/load/results/*.jsonas artifact; oncompare.mjsnon-zero, opens an issue usingpeter-evans/create-issue-from-filewith the diff report..github/workflows/load-smoke.yml— on every PR tomain; runsjust load-smoke; ~1 min budget; no regression gate, just "harness builds & runs."
9. Critical files to create / modify
Create:
tests/load/README.mdtests/load/test.envtests/load/run.sh,smoke.shtests/load/compare.mjstests/load/lib/auth.js,http.js,metrics.jstests/load/scenarios/smoke.js,folder_cascade.js,share_cascade_rebac.js,subject_group_nested.jstests/load/baseline/baseline.json(placeholder values)tests/load/results/.gitkeepsrc/bin/load-seed.rs.github/workflows/load-nightly.yml,load-smoke.yml
Modify:
Cargo.toml— add[[bin]] name = "load-seed" path = "src/bin/load-seed.rs"after themigrate-nfc-filenamesentryjustfile— append fourload*recipes.gitignore— addtests/load/results/*.jsonandtests/load/storage/
10. Reused existing utilities
tests/common/spawn-db.sh,stop-db.sh,init-test-schema.sh,server.env— reused as-is byrun.sh/smoke.sh.- Argon2id parameters from
src/infrastructure/services/password_hasher.rstest path — mirrored inload-seed.rsso test users can log in via the real auth flow. - DB schema migrations (
migrations/) — applied via the existinginit-test-schema.sh; no schema changes needed.
Verification
- Build:
cargo build --bin load-seedsucceeds;cargo clippy --all-features --all-targets -- -D warningsstays green. - Seeder works standalone:
just db && just load-seedpopulatesauth.users,storage.folders(depth-8 tree visible viaSELECT count(*) FROM storage.folders WHERE user_id = …),auth.subject_groups,storage.access_grants. - Smoke runs locally:
just load-smoke— exits 0 in under ~90 s, leaves no orphan postgres/oxicloud processes. - Full suite runs locally:
just load— k6 prints per-scenario p50/p95/p99;compare.mjsprints the diff table; sincebaseline.jsonis empty placeholders, expect a "REGRESSION" stub-report that the user reviews before runningjust load-baselineto accept the first real baseline. - Baseline workflow: after first
just load,just load-baselinerewritesbaseline.json; subsequentjust loadexits 0 (within tolerance). - CI smoke: trigger
load-smoke.ymlon a draft PR; confirm it completes under the time budget and uploads no artifacts on success. - CI nightly: manually trigger
load-nightly.ymlviaworkflow_dispatch; confirm artifact upload and (since no real baseline yet) confirm issue creation works.
Out of scope
- Real baseline values (first run on user's hardware seeds them).
- Self-hosted runner setup (workflow stub uses
ubuntu-latestwith a TODO comment). - Scenario B (many concurrent users) — deferred per user request.
- Goose / Rust-tester alternative — deferred until scenario B demonstrates K6 saturation issues.
- Additional scenarios (CalDAV / CardDAV / WebDAV / search depth) — the scaffolding makes them additive: drop a new file in
scenarios/, add its metric names tobaseline.json.