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
+111
View File
@@ -0,0 +1,111 @@
name: Load Nightly
# Nightly regression gate. Runs every k6 scenario, diffs against
# baseline/load.json, opens an issue if any metric regresses.
#
# IMPORTANT: shared GitHub-hosted runners produce noisy timings —
# regression signal is unreliable until this workflow is moved to a
# pinned self-hosted runner with consistent hardware. The cron run is
# informational until then; treat opened issues as "investigate" not
# "broken main."
on:
schedule:
# 03:00 UTC daily — outside US/EU working hours, low contention.
- cron: '0 3 * * *'
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
load:
name: k6 load suite + baseline diff
# Skip the scheduled run on forks — nightly is only meaningful against the
# canonical baseline.json on the upstream repo. Forks can still hit it
# manually via `workflow_dispatch` if they want, and the guard is bypassed
# for that path (event != schedule). Additional fork repositories are
# opted-in explicitly below.
if: github.event_name != 'schedule' || github.repository == 'AtalayaLabs/OxiCloud' || github.repository == 'EdouardVanbelle/OxiCloud'
# tunning on Ed's nuc to ensure stable environment
# treating regressions here as merge-blocking. ubuntu-latest is too noisy
# for trustworthy p95/p99 deltas.
runs-on: [self-hosted, nuc-loadtest]
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
# The self-hosted runner bind-mounts target/ as a persistent volume
# for fast incremental rebuilds. actions/checkout's default cleanup
# tries to rmdir it and hits EBUSY on the mount point. Git still
# syncs the working tree to the target SHA — only non-git files
# (target/, tests/load/results/) persist, which is what we want.
clean: false
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: load
- name: Install Node 20
# The self-hosted runner image ships Node 12, which can't parse the
# ES-module `.mjs` helpers (compare.mjs / merge-summaries.mjs /
# bake-baseline.mjs). Pin to a current LTS for both runners.
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install k6
uses: grafana/setup-k6-action@v1
- name: Build OxiCloud + load-seed (release)
# Single cargo invocation: load_seed_bin is an empty marker feature
# that gates the load-seed bin without changing oxicloud's dep
# graph, so cargo compiles oxicloud exactly once.
run: cargo build --release --features load_seed_bin --bin oxicloud --bin load-seed
- name: Run full load suite
id: load
run: bash tests/load/run.sh
env:
BUILD_TARGET: release
continue-on-error: true
- name: Upload raw k6 summaries
if: always()
uses: actions/upload-artifact@v4
with:
name: load-results-${{ github.run_id }}
path: tests/load/results/*.json
retention-days: 30
- name: Prepare regression report
if: steps.load.outcome == 'failure'
run: |
{
echo "## Load nightly regression"
echo ""
echo "Run: \`${{ github.run_id }}\` · Commit: \`${{ github.sha }}\`"
echo "Runner: \`${{ runner.name }}\` (\`${{ runner.os }}/${{ runner.arch }}\`)"
echo ""
echo "Compare exited non-zero — see uploaded artifact \`load-results-${{ github.run_id }}\` for raw summaries."
echo ""
echo "Baseline is anchored to the runner that produced it; cross-runner"
echo "comparisons (e.g. an artifact baked on different hardware) will"
echo "show large \"regressions\" that are really hardware deltas."
} > regression-issue.md
- name: Open issue on regression
# Skip on forks where Issues are disabled — the regression report is
# already in the artifact, and the workflow's exit code marks the run
# as failed regardless. Forks that want issue notifications can enable
# issues on their repo and remove this condition.
if: steps.load.outcome == 'failure' && github.repository == 'AtalayaLabs/OxiCloud'
uses: peter-evans/create-issue-from-file@v5
with:
title: "Load nightly: regression on ${{ github.sha }}"
content-filepath: regression-issue.md
labels: |
load-test
regression
+45
View File
@@ -0,0 +1,45 @@
name: Load Smoke
# PR-tier liveness check. Verifies the k6 load harness still builds and a
# single happy-path iteration runs against a freshly built server.
# NO regression gate — that's the nightly workflow's job.
on:
pull_request:
branches: [ main, dev ]
paths:
- 'tests/load/**'
- 'src/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'migrations/**'
- '.github/workflows/load-smoke.yml'
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
smoke:
name: k6 smoke (load harness)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: load
- name: Install k6
uses: grafana/setup-k6-action@v1
- name: Build OxiCloud (debug)
run: cargo build --bin oxicloud
- name: Run smoke scenario
run: bash tests/load/smoke.sh
env:
BUILD_TARGET: debug
+4
View File
@@ -109,3 +109,7 @@ wasm/oxicloud-hash/target/
.devenv/
.direnv/
.devenv-state/
# K6 load-test raw run outputs and per-run storage (baseline is committed)
tests/load/results/*.json
tests/load/storage/
+14
View File
@@ -89,6 +89,12 @@ integration_tests = []
# WASM plugin runtime (Extism). Opt-in: bundles wasmtime, a large engine most
# deployments won't use. Activation also requires OXICLOUD_ENABLE_PLUGINS=true.
plugins = ["dep:extism", "dep:toml", "dep:file-rotate"]
# Empty marker feature that gates the `load-seed` binary so it isn't built
# by default (and skipped in prod Docker builds). Kept separate from
# test_utils so enabling it doesn't change the oxicloud dependency graph —
# this lets one `cargo build` produce both `oxicloud` and `load-seed`
# without recompiling oxicloud with mockall in scope.
load_seed_bin = []
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] }
@@ -101,6 +107,14 @@ path = "src/bin/generate-openapi.rs"
name = "migrate-nfc-filenames"
path = "src/bin/migrate-nfc-filenames.rs"
[[bin]]
name = "load-seed"
path = "src/bin/load-seed.rs"
# Test fixture seeder. Gated behind the empty `load_seed_bin` feature so
# `cargo build --release` (and the prod Dockerfile) skip it. tests/load/run.sh
# and load-nightly.yml build it explicitly with --features load_seed_bin.
required-features = ["load_seed_bin"]
[build-dependencies]
oxc_allocator = "0.125.0"
oxc_parser = "0.125.0"
+5 -2
View File
@@ -18,7 +18,7 @@ RUN mkdir -p src/bin && \
echo 'fn main() { println!("Dummy build for caching dependencies"); }' > src/main.rs && \
echo 'fn main() {}' > src/bin/generate-openapi.rs && \
echo 'fn main() {}' > src/bin/migrate-nfc-filenames.rs && \
cargo build --release && \
cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames && \
rm -rf src static-dist target/release/deps/oxicloud* target/release/build/oxicloud-*
# ─── Stage 3: Build the application ──────────────────────────────────────────
@@ -38,7 +38,10 @@ COPY migrations migrations
COPY templates templates
# Build with all optimizations (DATABASE_URL only needed at compile-time for sqlx)
ARG DATABASE_URL="postgres://postgres:postgres@localhost/oxicloud"
RUN DATABASE_URL="${DATABASE_URL}" cargo build --release
# Explicit --bin list: defence-in-depth so the prod image never ships
# test-only bins (e.g. load-seed) even if `required-features` gating
# changes upstream.
RUN DATABASE_URL="${DATABASE_URL}" cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames
# ─── Stage 4: Minimal runtime image ──────────────────────────────────────────
FROM alpine:3.24.0
@@ -0,0 +1,187 @@
# 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.json` committed 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_dispatch` only.
## 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 as `src/infrastructure/services/password_hasher.rs` test path).
- One shared 0-byte blob row in `storage.blobs` with `ref_count = total_files`.
- Folder tree in `storage.folders`, **inserted level-by-level** so the `trg_folders_path` BEFORE-INSERT trigger can compute `path`/`lpath` from the parent chain.
- Files in `storage.files`, all referencing the shared blob hash for dedup.
- Subject groups in `auth.subject_groups` + membership rows in `auth.subject_group_members` (XOR `member_user_id`/`member_group_id`), forming a nested chain `G_root → G_mid → G_leaf → users`.
- ReBAC grants in `storage.access_grants` for a known set of test resources (used by `share_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 commit `4200209d`), 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.name` is CITEXT, max 64, RFC-5321 local-part shape — generated names use `g_NNN` pattern 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/login` body `{username, password}` → `{access_token, ...}`.
- Folder ops: `POST /api/folders`, `GET /api/folders/{id}/contents` (or `/contents/paginated` at high depth), `PUT /api/folders/{id}/move`, `POST /api/batch/folders/copy`, `DELETE /api/folders/{id}`. Handlers in `src/interfaces/api/handlers/folder_handler.rs` and `batch_handler.rs`.
- Grants: `POST /api/grants` (subject `{type, id|email}`, resource `{type, id}`, `role` or `permissions[]`), `GET /api/grants?resource_type=folder&resource_id=…`. Handler `src/interfaces/api/handlers/grant_handler.rs`.
- Groups: `POST /api/groups` (admin), `POST /api/groups/{id}/members` body `{user_id}` or `{group_id}`. Handler `src/interfaces/api/handlers/subject_group_handler.rs`. Nesting limit is 8 — `--group-depth 3` is well inside.
- Files: multipart `POST /api/files/upload` with fields `folder_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):
1. `spawn-db.sh` (reused from `tests/common/`)
2. `init-test-schema.sh` (reused)
3. `cargo run --bin load-seed -- <args>` against the test DB
4. Start `target/<profile>/oxicloud` on port 8088 with `OXICLOUD_STORAGE_PATH=tests/load/storage`
5. `wait_for_http http://localhost:8088/ready`
6. `k6 run --summary-export=results/$(date +%s).json scenarios/folder_cascade.js scenarios/share_cascade_rebac.js scenarios/subject_group_nested.js`
7. `node compare.mjs results/<latest>.json baseline/baseline.json` → exits non-zero on regression
8. `trap cleanup EXIT` tears 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
```json
{
"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`** — cron `0 3 * * *` UTC + `workflow_dispatch`; runs `just load` on `ubuntu-latest` initially with a `# TODO: replace with self-hosted runner — shared GH runners produce noisy results for regression detection` comment; uploads `tests/load/results/*.json` as artifact; on `compare.mjs` non-zero, opens an issue using `peter-evans/create-issue-from-file` with the diff report.
- **`.github/workflows/load-smoke.yml`** — on every PR to `main`; runs `just load-smoke`; ~1 min budget; no regression gate, just "harness builds & runs."
### 9. Critical files to create / modify
**Create:**
- `tests/load/README.md`
- `tests/load/test.env`
- `tests/load/run.sh`, `smoke.sh`
- `tests/load/compare.mjs`
- `tests/load/lib/auth.js`, `http.js`, `metrics.js`
- `tests/load/scenarios/smoke.js`, `folder_cascade.js`, `share_cascade_rebac.js`, `subject_group_nested.js`
- `tests/load/baseline/baseline.json` (placeholder values)
- `tests/load/results/.gitkeep`
- `src/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 the `migrate-nfc-filenames` entry
- `justfile` — append four `load*` recipes
- `.gitignore` — add `tests/load/results/*.json` and `tests/load/storage/`
### 10. Reused existing utilities
- `tests/common/spawn-db.sh`, `stop-db.sh`, `init-test-schema.sh`, `server.env` — reused as-is by `run.sh` / `smoke.sh`.
- Argon2id parameters from `src/infrastructure/services/password_hasher.rs` test path — mirrored in `load-seed.rs` so test users can log in via the real auth flow.
- DB schema migrations (`migrations/`) — applied via the existing `init-test-schema.sh`; no schema changes needed.
## Verification
1. **Build**: `cargo build --bin load-seed` succeeds; `cargo clippy --all-features --all-targets -- -D warnings` stays green.
2. **Seeder works standalone**: `just db && just load-seed` populates `auth.users`, `storage.folders` (depth-8 tree visible via `SELECT count(*) FROM storage.folders WHERE user_id = …`), `auth.subject_groups`, `storage.access_grants`.
3. **Smoke runs locally**: `just load-smoke` — exits 0 in under ~90 s, leaves no orphan postgres/oxicloud processes.
4. **Full suite runs locally**: `just load` — k6 prints per-scenario p50/p95/p99; `compare.mjs` prints the diff table; since `baseline.json` is empty placeholders, expect a "REGRESSION" stub-report that the user reviews before running `just load-baseline` to accept the first real baseline.
5. **Baseline workflow**: after first `just load`, `just load-baseline` rewrites `baseline.json`; subsequent `just load` exits 0 (within tolerance).
6. **CI smoke**: trigger `load-smoke.yml` on a draft PR; confirm it completes under the time budget and uploads no artifacts on success.
7. **CI nightly**: manually trigger `load-nightly.yml` via `workflow_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-latest` with 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 to `baseline.json`.
+22
View File
@@ -153,3 +153,25 @@ front-design:
api-test:
bash tests/api/run.sh
bash tests/webdav/run.sh
# k6 load suite — full scenarios + regression diff vs baseline/load.json.
# Used by the nightly workflow and on demand. Release build for fair timings.
load:
bash tests/load/run.sh
# k6 smoke — single happy-path iteration. PR-tier liveness check, no gate.
load-smoke:
bash tests/load/smoke.sh
# Re-run the full suite, then bake the latest summary into baseline/load.json.
# The leading `-` lets `run.sh`'s regression-exit not abort the bake step;
# review the diff and commit deliberately:
# chore(load): accept new baseline for <reason>
load-baseline:
-bash tests/load/run.sh
node tests/load/bake-baseline.mjs
# Standalone seeder (poking around in psql). Needs the test DB up:
# just db
load-seed:
cargo run --bin load-seed -- --depth 5 --fanout 4 --files-per-leaf 3
+746
View File
@@ -0,0 +1,746 @@
//! `load-seed` — bulk-seeds OxiCloud's test database for K6 load scenarios.
//!
//! Inserts users, a deep folder tree, files (all sharing one dedup'd blob),
//! nested subject groups, and ReBAC grants directly via sqlx — bypassing the
//! REST API so the seed phase is fast and doesn't pollute measured metrics.
//!
//! Only the resources each k6 scenario actively touches (the grant being
//! created, the move target, etc.) go through the HTTP API at run time.
//!
//! Run (from repo root, against the test DB on port 5433):
//!
//! ```bash
//! DATABASE_URL='postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test' \
//! cargo run --bin load-seed -- \
//! --depth 8 --fanout 3 --files-per-leaf 3 \
//! --extra-users 20 --group-depth 3 --group-fanout 5 \
//! --manifest tests/load/results/seed-manifest.json
//! ```
//!
//! Output: writes a JSON manifest at `--manifest` listing the IDs and
//! credentials each k6 scenario needs (admin login, grantee user, deep folder
//! IDs, group chain, etc.). K6 scenarios load this manifest at startup.
use argon2::password_hash::SaltString;
use argon2::{Algorithm, Argon2, Params, PasswordHasher, Version};
use rand_core::OsRng;
use serde::Serialize;
use sqlx::PgPool;
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::PathBuf;
use uuid::Uuid;
// ── CLI ──────────────────────────────────────────────────────────────────
struct Args {
depth: u32,
fanout: u32,
files_per_leaf: u32,
extra_users: u32,
group_depth: u32,
group_fanout: u32,
manifest: PathBuf,
password: String,
}
impl Args {
fn parse() -> Self {
// Defaults match tests/load/test.env; both bound the exponential cost
// of fanout^depth. Override via flags or LOAD_* env vars.
let mut depth = 5;
let mut fanout = 4;
let mut files_per_leaf = 3;
let mut extra_users = 20;
let mut group_depth = 3;
let mut group_fanout = 5;
let mut manifest = PathBuf::from("tests/load/results/seed-manifest.json");
let mut password = "TestPassword1!".to_string();
let argv: Vec<String> = env::args().collect();
let mut i = 1;
while i < argv.len() {
let key = argv[i].as_str();
let val = || {
argv.get(i + 1)
.unwrap_or_else(|| panic!("missing value for {}", key))
};
match key {
"--depth" => depth = val().parse().expect("--depth must be u32"),
"--fanout" => fanout = val().parse().expect("--fanout must be u32"),
"--files-per-leaf" => {
files_per_leaf = val().parse().expect("--files-per-leaf must be u32");
}
"--extra-users" => {
extra_users = val().parse().expect("--extra-users must be u32");
}
"--group-depth" => {
group_depth = val().parse().expect("--group-depth must be u32");
}
"--group-fanout" => {
group_fanout = val().parse().expect("--group-fanout must be u32");
}
"--manifest" => manifest = PathBuf::from(val()),
"--password" => password = val().clone(),
"--help" | "-h" => {
print_help();
std::process::exit(0);
}
other => panic!("unknown argument: {}", other),
}
i += 2;
}
Self {
depth,
fanout,
files_per_leaf,
extra_users,
group_depth,
group_fanout,
manifest,
password,
}
}
}
fn print_help() {
println!("load-seed — bulk-seed OxiCloud for k6 load tests");
println!();
println!("Reads DATABASE_URL from env. Inserts users, folders, files, groups, grants.");
println!("Writes a JSON manifest the k6 scenarios consume.");
println!();
println!("Flags (with defaults):");
println!(" --depth 5 folder tree depth (exponential: fanout^depth folders)");
println!(" --fanout 4 children per non-leaf folder");
println!(" --files-per-leaf 3 files inserted in each leaf folder");
println!(" --extra-users 20 non-admin users created");
println!(" --group-depth 3 nested subject-group chain length");
println!(" --group-fanout 5 users added directly to each leaf group");
println!(" --manifest <path> output manifest JSON path");
println!(" --password <pw> shared password for all seeded users");
}
// ── Manifest (consumed by k6 scenarios) ──────────────────────────────────
#[derive(Serialize)]
struct Manifest {
admin: UserCreds,
grantee: UserCreds,
group_member: UserCreds,
shared_subtree: SubtreeIds,
group_subtree: SubtreeIds,
nested_groups: NestedGroupIds,
/// Total folders seeded (informational).
total_folders: u64,
/// Total files seeded (informational).
total_files: u64,
}
#[derive(Serialize)]
struct UserCreds {
id: Uuid,
username: String,
password: String,
}
#[derive(Serialize)]
struct SubtreeIds {
/// Root of the subtree the grant is attached to.
root: Uuid,
/// A folder at depth 4 inside the subtree (for mid-depth fetches).
depth4: Uuid,
/// A folder at depth 8 inside the subtree (for mid-depth fetches).
depth8: Uuid,
/// A folder at depth N (== `--depth`) inside the subtree (for deep fetches).
deepest: Uuid,
}
#[derive(Serialize)]
struct NestedGroupIds {
/// Outermost group — the one referenced by the grant. Contains `mid` as a member.
root: Uuid,
/// Intermediate group — member of `root`, contains `leaf` as a member.
mid: Uuid,
/// Innermost group — member of `mid`, contains `group_member` user as a direct member.
leaf: Uuid,
}
// ── Main ─────────────────────────────────────────────────────────────────
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Best-effort: load .env if present (mirrors the rest of the codebase).
let _ = dotenvy::dotenv();
let args = Args::parse();
let database_url = env::var("DATABASE_URL").map_err(|_| {
"DATABASE_URL must be set (e.g. postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test)"
})?;
println!(
"[load-seed] connecting to {}",
sanitize_url_for_log(&database_url)
);
let pool = PgPool::connect(&database_url).await?;
println!("[load-seed] wiping previous load-test data…");
wipe(&pool).await?;
println!("[load-seed] hashing shared password…");
let password_hash = hash_password(&args.password);
println!(
"[load-seed] inserting users (1 admin + {})…",
args.extra_users
);
let admin = insert_user(&pool, "load_admin", "admin", &password_hash).await?;
let extra_users = insert_extra_users(&pool, args.extra_users, &password_hash).await?;
// Pick a grantee (user[0]) and a group member (user[1]).
let grantee = extra_users
.first()
.ok_or("need at least one extra user for grantee")?
.clone();
let group_member = extra_users
.get(1)
.ok_or("need at least two extra users")?
.clone();
println!("[load-seed] inserting shared blob…");
let blob_hash = insert_shared_blob(&pool).await?;
println!(
"[load-seed] building folder tree (depth={}, fanout={})…",
args.depth, args.fanout
);
// Two parallel subtrees off root: one for the user-grant scenario, one for
// the group-grant scenario. Each has its own depth/fanout shape so a single
// grant cascades over a known fixed number of descendants.
let shared_subtree =
build_subtree(&pool, admin.id, "shared_root", args.depth, args.fanout).await?;
let group_subtree =
build_subtree(&pool, admin.id, "group_root", args.depth, args.fanout).await?;
let total_folders = shared_subtree.all_ids.len() as u64 + group_subtree.all_ids.len() as u64;
let total_leaves = shared_subtree.leaves.len() + group_subtree.leaves.len();
println!(
"[load-seed] inserting files (files_per_leaf={}, leaves={})…",
args.files_per_leaf, total_leaves
);
let total_files = insert_files(
&pool,
admin.id,
&blob_hash,
&shared_subtree.leaves,
&group_subtree.leaves,
args.files_per_leaf,
)
.await?;
println!(
"[load-seed] building nested group chain (depth={}, fanout={})…",
args.group_depth, args.group_fanout
);
let nested_groups = build_group_chain(
&pool,
admin.id,
&extra_users,
&group_member.id,
args.group_depth,
args.group_fanout,
)
.await?;
println!("[load-seed] inserting grants…");
// Grant the grantee read on shared_subtree.root.
insert_grant(
&pool,
"user",
grantee.id,
"folder",
shared_subtree.root,
"read",
admin.id,
)
.await?;
// Grant the outermost group read on group_subtree.root.
insert_grant(
&pool,
"group",
nested_groups.root,
"folder",
group_subtree.root,
"read",
admin.id,
)
.await?;
let manifest = Manifest {
admin: UserCreds {
id: admin.id,
username: admin.username,
password: args.password.clone(),
},
grantee: UserCreds {
id: grantee.id,
username: grantee.username,
password: args.password.clone(),
},
group_member: UserCreds {
id: group_member.id,
username: group_member.username,
password: args.password.clone(),
},
shared_subtree: SubtreeIds {
root: shared_subtree.root,
depth4: shared_subtree.depth4,
depth8: shared_subtree.depth8,
deepest: shared_subtree.deepest,
},
group_subtree: SubtreeIds {
root: group_subtree.root,
depth4: group_subtree.depth4,
depth8: group_subtree.depth8,
deepest: group_subtree.deepest,
},
nested_groups,
total_folders,
total_files,
};
if let Some(parent) = args.manifest.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(&manifest)?;
fs::write(&args.manifest, json)?;
println!(
"[load-seed] manifest written → {} ({} folders, {} files)",
args.manifest.display(),
total_folders,
total_files
);
Ok(())
}
// ── Wipe ─────────────────────────────────────────────────────────────────
async fn wipe(pool: &PgPool) -> Result<(), sqlx::Error> {
// Users starting with `load_` are the only ones this seeder ever creates.
// CASCADE on auth.users → storage.folders / storage.files / grants / group
// memberships, so a single DELETE wipes everything load-test-related.
sqlx::query("DELETE FROM auth.users WHERE username LIKE 'load_%'")
.execute(pool)
.await?;
// Groups starting with `load_` aren't owned by users; clear them explicitly.
sqlx::query("DELETE FROM auth.subject_groups WHERE name LIKE 'load_%'")
.execute(pool)
.await?;
// The shared dedup blob is identified by its fixed hash sentinel below.
sqlx::query("DELETE FROM storage.blobs WHERE hash = $1")
.bind(SHARED_BLOB_HASH)
.execute(pool)
.await?;
Ok(())
}
// ── Password ─────────────────────────────────────────────────────────────
/// Light Argon2id parameters — same shape as the password_hasher test path
/// (`src/infrastructure/services/password_hasher.rs::test_hasher`).
/// Production hashes verify regardless of m/t/p because Argon2 reads the
/// parameters back from the hash string itself.
fn hash_password(password: &str) -> String {
let params = Params::new(16384, 1, 1, None).expect("valid argon2 params");
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let salt = SaltString::generate(&mut OsRng);
argon2
.hash_password(password.as_bytes(), &salt)
.expect("argon2 hash")
.to_string()
}
// ── Users ────────────────────────────────────────────────────────────────
#[derive(Clone)]
struct SeededUser {
id: Uuid,
username: String,
}
async fn insert_user(
pool: &PgPool,
username: &str,
role: &str,
password_hash: &str,
) -> Result<SeededUser, sqlx::Error> {
let email = format!("{}@load.test.invalid", username);
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO auth.users (username, email, password_hash, role)
VALUES ($1, $2, $3, $4::auth.userrole)
RETURNING id",
)
.bind(username)
.bind(email)
.bind(password_hash)
.bind(role)
.fetch_one(pool)
.await?;
Ok(SeededUser {
id: row.0,
username: username.to_string(),
})
}
async fn insert_extra_users(
pool: &PgPool,
count: u32,
password_hash: &str,
) -> Result<Vec<SeededUser>, sqlx::Error> {
if count == 0 {
return Ok(Vec::new());
}
let usernames: Vec<String> = (0..count).map(|i| format!("load_user_{:04}", i)).collect();
let emails: Vec<String> = usernames
.iter()
.map(|u| format!("{}@load.test.invalid", u))
.collect();
// Bulk insert via UNNEST — one round-trip for all N users.
let rows: Vec<(Uuid, String)> = sqlx::query_as(
"INSERT INTO auth.users (username, email, password_hash, role)
SELECT u.username, u.email, $1::text, 'user'::auth.userrole
FROM UNNEST($2::text[], $3::text[]) AS u(username, email)
RETURNING id, username",
)
.bind(password_hash)
.bind(&usernames)
.bind(&emails)
.fetch_all(pool)
.await?;
// RETURNING order is implementation-defined — sort by username so the
// caller can rely on `extra_users[0]` being `load_user_0000`.
let mut users: Vec<SeededUser> = rows
.into_iter()
.map(|(id, username)| SeededUser { id, username })
.collect();
users.sort_by(|a, b| a.username.cmp(&b.username));
Ok(users)
}
// ── Blob ─────────────────────────────────────────────────────────────────
/// All seeded files share this single zero-byte blob. Its ref_count is bumped
/// in lockstep with file inserts; the file_metadata schema doesn't require a
/// real on-disk blob for the dedup index to be consistent.
const SHARED_BLOB_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
async fn insert_shared_blob(pool: &PgPool) -> Result<String, sqlx::Error> {
sqlx::query(
"INSERT INTO storage.blobs (hash, size, ref_count, content_type)
VALUES ($1, 0, 0, 'application/octet-stream')
ON CONFLICT (hash) DO NOTHING",
)
.bind(SHARED_BLOB_HASH)
.execute(pool)
.await?;
Ok(SHARED_BLOB_HASH.to_string())
}
// ── Folder tree ──────────────────────────────────────────────────────────
struct Subtree {
root: Uuid,
depth4: Uuid,
depth8: Uuid,
deepest: Uuid,
all_ids: Vec<Uuid>,
leaves: Vec<Uuid>,
}
/// Build a folder tree under `parent=NULL` rooted at `root_name`.
///
/// Inserts level-by-level so `trg_folders_path` resolves `path`/`lpath` from
/// the already-committed parent rows. Returns the root, a depth-4 sample, the
/// deepest leaf, the full id set, and the leaf ids (used for file seeding).
async fn build_subtree(
pool: &PgPool,
user_id: Uuid,
root_name: &str,
depth: u32,
fanout: u32,
) -> Result<Subtree, sqlx::Error> {
// Per-row BEFORE INSERT trigger (compute_folder_path) makes each level cost
// O(parents × fanout); past depth 6 with fanout 5 the seed time grows fast.
// Print the count BEFORE each level so it's clear which level is in flight
// and how many rows are about to be inserted.
let predicted: u64 = (0..=depth).map(|l| (fanout as u64).pow(l)).sum();
println!(
" subtree '{}': ~{} folders predicted",
root_name, predicted
);
// Level 0 — the root folder.
let root: (Uuid,) = sqlx::query_as(
"INSERT INTO storage.folders (name, parent_id, user_id)
VALUES ($1, NULL, $2)
RETURNING id",
)
.bind(root_name)
.bind(user_id)
.fetch_one(pool)
.await?;
let root_id = root.0;
let mut all_ids = vec![root_id];
let mut current_level: Vec<Uuid> = vec![root_id];
let mut depth4: Option<Uuid> = None;
let mut depth8: Option<Uuid> = None;
let mut deepest: Uuid = root_id;
for level in 1..=depth {
// Build the (parent_id, name) pairs for this level.
let parents: Vec<Uuid> = current_level
.iter()
.flat_map(|p| std::iter::repeat_n(*p, fanout as usize))
.collect();
let names: Vec<String> = current_level
.iter()
.enumerate()
.flat_map(|(pi, _)| (0..fanout).map(move |c| format!("l{}_{}_{}", level, pi, c)))
.collect();
let started = std::time::Instant::now();
println!(
" level {}/{}: inserting {} folders…",
level,
depth,
parents.len()
);
let rows: Vec<(Uuid,)> = sqlx::query_as(
"INSERT INTO storage.folders (name, parent_id, user_id)
SELECT f.name, f.parent_id, $1
FROM UNNEST($2::uuid[], $3::text[]) AS f(parent_id, name)
RETURNING id",
)
.bind(user_id)
.bind(&parents)
.bind(&names)
.fetch_all(pool)
.await?;
let level_ids: Vec<Uuid> = rows.into_iter().map(|(id,)| id).collect();
println!(
" level {}/{}: done in {:.1}s",
level,
depth,
started.elapsed().as_secs_f64()
);
if level == 4 {
depth4 = level_ids.first().copied();
}
if level == 8 {
depth8 = level_ids.first().copied();
}
if level == depth {
deepest = *level_ids.first().expect("non-empty deepest level");
}
all_ids.extend(&level_ids);
current_level = level_ids;
}
let depth4 = depth4.unwrap_or(deepest);
let depth8 = depth8.unwrap_or(deepest);
let leaves = current_level;
Ok(Subtree {
root: root_id,
depth4,
depth8,
deepest,
all_ids,
leaves,
})
}
// ── Files ────────────────────────────────────────────────────────────────
async fn insert_files(
pool: &PgPool,
user_id: Uuid,
blob_hash: &str,
shared_leaves: &[Uuid],
group_leaves: &[Uuid],
files_per_leaf: u32,
) -> Result<u64, sqlx::Error> {
if files_per_leaf == 0 {
return Ok(0);
}
let all_leaves: Vec<Uuid> = shared_leaves
.iter()
.chain(group_leaves.iter())
.copied()
.collect();
if all_leaves.is_empty() {
return Ok(0);
}
// Build (folder_id, name) pairs.
let folder_ids: Vec<Uuid> = all_leaves
.iter()
.flat_map(|f| std::iter::repeat_n(*f, files_per_leaf as usize))
.collect();
let names: Vec<String> = (0..all_leaves.len())
.flat_map(|li| (0..files_per_leaf).map(move |fi| format!("file_{}_{}.txt", li, fi)))
.collect();
sqlx::query(
"INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
SELECT f.name, f.folder_id, $1, $2, 0, 'text/plain'
FROM UNNEST($3::uuid[], $4::text[]) AS f(folder_id, name)",
)
.bind(user_id)
.bind(blob_hash)
.bind(&folder_ids)
.bind(&names)
.execute(pool)
.await?;
let total = folder_ids.len() as i64;
sqlx::query("UPDATE storage.blobs SET ref_count = ref_count + $1 WHERE hash = $2")
.bind(total)
.bind(blob_hash)
.execute(pool)
.await?;
Ok(total as u64)
}
// ── Subject groups ───────────────────────────────────────────────────────
async fn build_group_chain(
pool: &PgPool,
admin_id: Uuid,
extra_users: &[SeededUser],
group_member_user_id: &Uuid,
depth: u32,
fanout: u32,
) -> Result<NestedGroupIds, Box<dyn std::error::Error>> {
if depth < 2 {
return Err(format!("--group-depth must be ≥ 2 (got {})", depth).into());
}
// Create `depth` groups: g_0 (outermost) → g_1 → … → g_{depth-1} (innermost).
// Insert one row per group; CITEXT name must match the RFC-5321 regex,
// and the `load_` prefix lets the wipe step find them again.
let salt: String = Uuid::new_v4().simple().to_string();
let names: Vec<String> = (0..depth)
.map(|i| format!("load_g_{}_{}", salt, i))
.collect();
let mut group_ids: HashMap<u32, Uuid> = HashMap::with_capacity(depth as usize);
for (i, name) in names.iter().enumerate() {
let row: (Uuid,) =
sqlx::query_as("INSERT INTO auth.subject_groups (name) VALUES ($1) RETURNING id")
.bind(name)
.fetch_one(pool)
.await?;
group_ids.insert(i as u32, row.0);
}
// Wire the chain: g_i contains g_{i+1} as a member (XOR: member_group_id set).
for i in 0..(depth - 1) {
let parent = group_ids[&i];
let child = group_ids[&(i + 1)];
sqlx::query(
"INSERT INTO auth.subject_group_members (group_id, member_user_id, member_group_id, added_by)
VALUES ($1, NULL, $2, $3)",
)
.bind(parent)
.bind(child)
.bind(admin_id)
.execute(pool)
.await?;
}
// Innermost group gets `fanout` direct user members, ensuring the
// group_member sentinel user is always among them.
let leaf = group_ids[&(depth - 1)];
let user_ids: Vec<Uuid> = {
let mut v = vec![*group_member_user_id];
for u in extra_users.iter().filter(|u| u.id != *group_member_user_id) {
if v.len() as u32 >= fanout {
break;
}
v.push(u.id);
}
v
};
let leafs: Vec<Uuid> = std::iter::repeat_n(leaf, user_ids.len()).collect();
let added_by_col: Vec<Uuid> = std::iter::repeat_n(admin_id, user_ids.len()).collect();
sqlx::query(
"INSERT INTO auth.subject_group_members (group_id, member_user_id, member_group_id, added_by)
SELECT g.group_id, g.member_user_id, NULL, g.added_by
FROM UNNEST($1::uuid[], $2::uuid[], $3::uuid[]) AS g(group_id, member_user_id, added_by)",
)
.bind(&leafs)
.bind(&user_ids)
.bind(&added_by_col)
.execute(pool)
.await?;
Ok(NestedGroupIds {
root: group_ids[&0],
mid: group_ids[&(depth / 2)],
leaf,
})
}
// ── Grants ───────────────────────────────────────────────────────────────
async fn insert_grant(
pool: &PgPool,
subject_type: &str,
subject_id: Uuid,
resource_type: &str,
resource_id: Uuid,
permission: &str,
granted_by: Uuid,
) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO storage.access_grants
(subject_type, subject_id, resource_type, resource_id, permission, granted_by)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (subject_type, subject_id, resource_type, resource_id, permission) DO NOTHING",
)
.bind(subject_type)
.bind(subject_id)
.bind(resource_type)
.bind(resource_id)
.bind(permission)
.bind(granted_by)
.execute(pool)
.await?;
Ok(())
}
// ── Utilities ────────────────────────────────────────────────────────────
fn sanitize_url_for_log(url: &str) -> String {
// Strip "user:password@" from the URL so logs don't leak credentials.
if let Some(at) = url.find('@')
&& let Some(scheme_end) = url.find("://")
{
let scheme = &url[..scheme_end + 3];
let rest = &url[at..];
return format!("{}***{}", scheme, rest);
}
url.to_string()
}
+17 -4
View File
@@ -32,11 +32,24 @@ wait_for_postgres_ready() {
# Belt-and-braces: pg_isready returns 0 as soon as the server accepts
# connections, but a query may still race the very first request. One
# successful SELECT confirms the round-trip works end-to-end.
PGPASSWORD=oxicloud_test psql -h 127.0.0.1 -p 5433 -U oxicloud_test -d oxicloud_test \
-v ON_ERROR_STOP=1 -c 'SELECT 1' >/dev/null 2>&1 || {
echo "Postgres reported ready but a sample query failed" >&2
#
# Retry the probe a handful of times — under CPU pressure (e.g. a parallel
# cargo build hammering a self-hosted runner) the role/db init can complete
# a beat after pg_isready returns success. A single-shot probe in that
# window produces spurious "Postgres reported ready but a sample query
# failed" failures. Show the last error if every retry fails so operators
# see the actual psql diagnostic.
local last_err
for _ in 1 2 3 4 5 6 7 8 9 10; do
if last_err=$(PGPASSWORD=oxicloud_test psql -h 127.0.0.1 -p 5433 \
-U oxicloud_test -d oxicloud_test \
-v ON_ERROR_STOP=1 -c 'SELECT 1' 2>&1 >/dev/null); then
return 0
fi
sleep 0.5
done
echo "Postgres reported ready but a sample query failed after 10 retries: $last_err" >&2
exit 1
}
}
echo "[setup] Starting test postgres..."
+91 -8
View File
@@ -1,11 +1,94 @@
iPurpose of this directory: implement load test and identify response time under:
- heavy load
- many content
- many sub folders and sharing
# tests/load/
Goal is to identify inflections and regression when a new feature is added
K6 load-test suite for OxiCloud. Detects performance regressions by comparing
each run's p50/p95/p99 against a committed baseline.
No accemtance criteria yet
## Suites
Load test via k6 ?
or drill (written in Rust) ?
- **smoke** — `just load-smoke`. Single VU, single iteration of one scenario.
Verifies the harness still builds and the server boots. ~1 minute. Run on
every PR. No regression gate.
- **full** — `just load`. Runs every scenario under `scenarios/` against a
seeded database. Compares results against `baseline/load.json`; exits
non-zero on regression beyond the per-metric tolerance. Run nightly on
`main` and manually.
## Scenarios
| File | What it measures |
| --------------------------------- | ------------------------------------------------------------------------------- |
| `scenarios/smoke.js` | Login + create folder + upload + list root + delete. Liveness only. |
| `scenarios/folder_cascade.js` | `GET /contents`, `PUT /move`, batch copy, `DELETE` on a depth-8 fanout-5 tree. |
| `scenarios/share_cascade_rebac.js`| `POST /grants` on a folder, then descendants fetched by the grantee. |
| `scenarios/subject_group_nested.js`| Grant via a 3-level nested group chain, then descendants fetched by a member. |
Add new scenarios as `scenarios/<name>.js`; register their metric names in
`baseline/load.json` (or `baseline/smoke.json` if you wire smoke gating).
## Seeding
`src/bin/load-seed.rs` (invoked by `run.sh`) bulk-inserts fixtures directly
via sqlx: users, deep folder tree, files (all sharing one dedup'd blob),
nested subject groups, ReBAC grants. Only the resources each scenario
actively touches (the grant being created, the move target, etc.) go
through the REST API at run time — that is the measured hot path.
## Baseline & regression detection
Baselines live under `baseline/`, split by which runner grades them:
| File | Used by | Regression-gated? |
| --------------------- | ------------------------ | ----------------- |
| `baseline/load.json` | `just load` (`run.sh`) | Yes |
| `baseline/smoke.json` | `just load-smoke` | Not yet (see below) |
Both have the same shape — one entry per `<scenario>.<op>`:
```json
{
"folder_cascade.list_depth1": { "p50": 0.97, "p95": 2.04, "p99": 4.82, "tolerance_pct": 10 }
}
```
K6 scenarios load the relevant file at startup and set `thresholds` from
it, so a regression fails the K6 run directly. `compare.mjs` also prints a
human-readable diff table after the run and exits non-zero if any metric
regresses beyond its tolerance.
The smoke scenario is currently **not regression-gated** — `smoke.sh` runs
the scenario but doesn't call `compare.mjs`. When you decide it should be,
mirror the `run.sh` pattern and point `compare.mjs` at
`baseline/smoke.json`.
**Updating a baseline is deliberate.** Run `just load-baseline` to rewrite
`baseline/load.json` from the latest run, then commit it as
`chore(load): accept new baseline for <reason>`. Never auto-update. For
`smoke.json`, pass explicit paths to `bake-baseline.mjs`.
## Local workflow
```bash
just db # start the test postgres (port 5433)
just load-seed # seed alone (poking around in psql)
just load-smoke # fast liveness check
just load # full suite + regression diff
just load-baseline # rerun, accept current numbers as the new bar
```
## CI
- `.github/workflows/load-smoke.yml` — every PR. ~1 min. No regression gate.
- `.github/workflows/load-nightly.yml` — cron daily + `workflow_dispatch`.
Runs the full suite, uploads results as artifact, opens an issue on
regression. Currently runs on `ubuntu-latest`; replace with a stable
self-hosted runner for trustworthy regression signal (shared GitHub
runners produce noisy timings).
## Why K6 and not Goose/drill
K6 is Go-based (JS scripting in `goja`), not Node. For scenario A, the
client adds ~50–200µs per request — negligible vs. multi-ms server work,
and regression deltas only need *consistency*. K6 also gives us
thresholds-as-DSL, native InfluxDB/Prometheus output, and faster
scenario iteration than a Rust tester would. Reassess when scenario B
(many concurrent users) demonstrates K6 saturation issues.
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
// bake-baseline.mjs — convert the latest k6 --summary-export into the
// baseline shape that compare.mjs reads.
//
// Tolerance values from the existing baseline are preserved; only the p50/
// p95/p99 numbers are overwritten. Run this after `just load` to lock in a
// new accepted bar, then commit the result deliberately.
//
// Usage:
// node bake-baseline.mjs # auto-pick latest summary, target baseline/load.json
// node bake-baseline.mjs <summary> <baseline> # explicit paths (e.g. baseline/smoke.json)
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { argv, exit } from 'node:process';
const HERE = dirname(fileURLToPath(import.meta.url));
function latestSummary() {
const dir = join(HERE, 'results');
const candidates = readdirSync(dir)
.filter((f) => f.startsWith('run-') && f.endsWith('.json'))
.map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime);
if (candidates.length === 0) {
console.error('bake-baseline: no run-*.json under tests/load/results/');
exit(2);
}
return join(dir, candidates[0].f);
}
const summaryPath = argv[2] ? resolve(argv[2]) : latestSummary();
const baselinePath = argv[3] ? resolve(argv[3]) : join(HERE, 'baseline/load.json');
const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
const metricKeyFor = (op) => `http_req_duration{op:${op}}`;
const PERCENTILES = [
{ baseline: 'p50', k6: 'med' },
{ baseline: 'p95', k6: 'p(95)' },
{ baseline: 'p99', k6: 'p(99)' },
];
let updated = 0;
let missing = 0;
for (const [op, entry] of Object.entries(baseline)) {
if (op.startsWith('_')) continue;
const metric = summary.metrics?.[metricKeyFor(op)];
if (!metric) {
console.warn(` missing: ${op} (no data in ${summaryPath})`);
missing++;
continue;
}
// k6 --summary-export puts percentile values directly on the metric object
// (alongside `thresholds`), not inside a `.values` wrapper.
let touched = false;
for (const p of PERCENTILES) {
const cur = metric[p.k6];
if (cur === undefined) continue;
entry[p.baseline] = Number(cur.toFixed(2));
touched = true;
}
if (touched) updated++;
}
writeFileSync(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`);
console.log(`bake-baseline: updated ${updated} metric(s), missing ${missing} in ${baselinePath}`);
console.log(`Source: ${summaryPath}`);
console.log('Commit deliberately: chore(load): accept new baseline for <reason>');
+81
View File
@@ -0,0 +1,81 @@
{
"_comment": "Baseline for `just load` — the long scenarios. Each entry: p50/p95/p99 (ms) + tolerance_pct (regression threshold). Updated by `just load-baseline`. Keys must match the `op` tag the matching scenario emits — adding a scenario means adding entries here in the same PR. Smoke metrics live in smoke.json.",
"folder_cascade.list_depth1": {
"p50": 0.35,
"p95": 2.25,
"p99": 4.73,
"tolerance_pct": 10
},
"folder_cascade.list_depth4": {
"p50": 0.36,
"p95": 0.92,
"p99": 2.39,
"tolerance_pct": 10
},
"folder_cascade.list_depth8": {
"p50": 0.34,
"p95": 0.63,
"p99": 1.9,
"tolerance_pct": 10
},
"folder_cascade.list_depth_deep": {
"p50": 0.3,
"p95": 0.61,
"p99": 2.03,
"tolerance_pct": 10
},
"share_cascade_rebac.list_grants": {
"p50": 0.4,
"p95": 1.16,
"p99": 1.68,
"tolerance_pct": 10
},
"share_cascade_rebac.fetch_as_grantee_depth1": {
"p50": 0.51,
"p95": 1.14,
"p99": 3.02,
"tolerance_pct": 10
},
"share_cascade_rebac.fetch_as_grantee_depth4": {
"p50": 0.53,
"p95": 1.43,
"p99": 2.22,
"tolerance_pct": 10
},
"share_cascade_rebac.fetch_as_grantee_depth8": {
"p50": 0.52,
"p95": 0.89,
"p99": 1.48,
"tolerance_pct": 10
},
"share_cascade_rebac.fetch_as_grantee_depth_deep": {
"p50": 0.51,
"p95": 1.04,
"p99": 1.64,
"tolerance_pct": 10
},
"subject_group_nested.fetch_as_member_depth1": {
"p50": 0.48,
"p95": 0.63,
"p99": 1.41,
"tolerance_pct": 10
},
"subject_group_nested.fetch_as_member_depth4": {
"p50": 0.5,
"p95": 0.58,
"p99": 0.65,
"tolerance_pct": 10
},
"subject_group_nested.fetch_as_member_depth8": {
"p50": 0.48,
"p95": 0.53,
"p99": 0.62,
"tolerance_pct": 10
},
"subject_group_nested.fetch_as_member_depth_deep": {
"p50": 0.45,
"p95": 0.62,
"p99": 0.63,
"tolerance_pct": 10
}
}
+33
View File
@@ -0,0 +1,33 @@
{
"_comment": "Baseline for `just load-smoke` — the smoke scenario. Placeholder values; smoke is not currently regression-gated by smoke.sh. When you decide it should be, point compare.mjs at this file from smoke.sh (mirror the run.sh pattern) and re-bake.",
"smoke.login": {
"p50": 50,
"p95": 200,
"p99": 400,
"tolerance_pct": 15
},
"smoke.create_folder": {
"p50": 20,
"p95": 80,
"p99": 150,
"tolerance_pct": 15
},
"smoke.upload_tiny": {
"p50": 30,
"p95": 120,
"p99": 250,
"tolerance_pct": 15
},
"smoke.list_root": {
"p50": 15,
"p95": 60,
"p99": 120,
"tolerance_pct": 15
},
"smoke.delete_folder": {
"p50": 20,
"p95": 80,
"p99": 150,
"tolerance_pct": 15
}
}
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env node
// compare.mjs — regression diff for k6 load-test runs.
//
// Reads a k6 --summary-export JSON and the committed baseline, diffs the
// p50/p95/p99 of every baseline metric against the current run, prints a
// human-readable table, and exits non-zero if any metric regresses beyond
// its tolerance.
//
// Usage:
// node compare.mjs <summary.json> <baseline.json>
//
// Exit codes:
// 0 — every metric within tolerance.
// 1 — one or more metrics regressed, or a baseline metric is absent from
// the current summary (suite drift — either a scenario was deleted
// or a tag was renamed without updating baseline.json).
import { readFileSync } from 'node:fs';
import { argv, exit } from 'node:process';
if (argv.length < 4) {
console.error('usage: compare.mjs <summary.json> <baseline.json>');
exit(2);
}
const summaryPath = argv[2];
const baselinePath = argv[3];
const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
// k6 summary metrics are keyed verbatim with the tag: e.g.
// "http_req_duration{op:smoke.login}".
const metricKeyFor = (op) => `http_req_duration{op:${op}}`;
// Map k6 percentile field names → baseline field names.
const PERCENTILES = [
{ baseline: 'p50', k6: 'med' },
{ baseline: 'p95', k6: 'p(95)' },
{ baseline: 'p99', k6: 'p(99)' },
];
const rows = [];
let anyRegression = false;
let anyMissing = false;
for (const [op, base] of Object.entries(baseline)) {
if (op.startsWith('_')) continue; // skip _comment etc.
const metric = summary.metrics?.[metricKeyFor(op)];
if (!metric) {
rows.push({ op, status: 'MISSING', detail: 'no data for this tag in current summary' });
anyMissing = true;
continue;
}
const tol = (base.tolerance_pct ?? 10) / 100;
// Noise-floor guard: at sub-millisecond percentiles, a 10% tolerance is
// smaller than a single context switch. Require the absolute delta to
// exceed `min_delta_ms` too — otherwise the swing is below the measurement
// floor and we don't flag it. Default 0.5ms is conservative for the load
// suite's typical 0.3–3ms range. Override per metric in baseline.json.
const minDelta = base.min_delta_ms ?? 0.5;
for (const p of PERCENTILES) {
const baseVal = base[p.baseline];
// k6 --summary-export puts percentile values directly on the metric, not
// inside a `.values` wrapper.
const curVal = metric[p.k6];
if (baseVal === undefined || curVal === undefined) continue;
const deltaPct = ((curVal - baseVal) / baseVal) * 100;
const deltaAbs = curVal - baseVal;
const limitPct = baseVal * (1 + tol);
// Regression only if BOTH the % rule AND the absolute floor are breached.
const regressed = curVal > limitPct && deltaAbs > minDelta;
if (regressed) anyRegression = true;
rows.push({
op,
percentile: p.baseline,
base: baseVal,
cur: curVal,
deltaPct,
regressed,
tol: base.tolerance_pct ?? 10,
});
}
}
// ── Render table ──────────────────────────────────────────────────────────
const ms = (v) => `${v.toFixed(1)}ms`;
const pct = (v) => `${v >= 0 ? '+' : ''}${v.toFixed(1)}%`;
function pad(s, n) {
if (s.length >= n) return s;
return s + ' '.repeat(n - s.length);
}
const colOp = Math.max(20, ...rows.map((r) => r.op.length));
const colP = 5;
const colVal = 11;
console.log();
console.log(
pad('metric', colOp + 1) +
pad('pctl', colP + 1) +
pad('baseline', colVal + 1) +
pad('current', colVal + 1) +
pad('delta', 9) +
'status',
);
console.log('-'.repeat(colOp + colP + colVal * 2 + 9 + 12));
for (const r of rows) {
if (r.status === 'MISSING') {
console.log(`${pad(r.op, colOp + 1)}${pad('-', colP + 1)}${pad('-', colVal + 1)}${pad('-', colVal + 1)}${pad('-', 9)}MISSING — ${r.detail}`);
continue;
}
const status = r.regressed ? `REGRESSION (tol ±${r.tol}%)` : 'ok';
console.log(
pad(r.op, colOp + 1) +
pad(r.percentile, colP + 1) +
pad(ms(r.base), colVal + 1) +
pad(ms(r.cur), colVal + 1) +
pad(pct(r.deltaPct), 9) +
status,
);
}
console.log();
if (anyRegression || anyMissing) {
if (anyRegression) console.error('compare.mjs: one or more metrics regressed beyond tolerance.');
if (anyMissing) console.error('compare.mjs: one or more baseline metrics are missing from the current summary.');
exit(1);
}
console.log('compare.mjs: all metrics within tolerance.');
exit(0);
+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);
}
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env node
// merge-summaries.mjs — combine multiple k6 --summary-export outputs into
// one. Used because k6 accepts a single script per invocation, but our
// regression-diff tooling (compare.mjs, bake-baseline.mjs) expects one
// summary per run.
//
// Per-scenario summaries are disjoint in their metric tags
// (folder_cascade.* vs share_cascade_rebac.* vs subject_group_nested.*),
// so merging is just a union of the `metrics` maps. The top-level
// envelope (root_group, options, etc.) is taken from the first input.
//
// Usage:
// node merge-summaries.mjs <in1.json> [in2.json …] <out.json>
import { readFileSync, writeFileSync } from 'node:fs';
import { argv, exit } from 'node:process';
if (argv.length < 5) {
console.error('usage: merge-summaries.mjs <in1.json> [in2.json …] <out.json>');
exit(2);
}
const inputs = argv.slice(2, -1);
const output = argv[argv.length - 1];
const merged = JSON.parse(readFileSync(inputs[0], 'utf8'));
merged.metrics = { ...merged.metrics };
for (const path of inputs.slice(1)) {
const next = JSON.parse(readFileSync(path, 'utf8'));
for (const [k, v] of Object.entries(next.metrics ?? {})) {
// If two scenarios both report a global metric (e.g. http_req_duration
// with no op tag), the second wins — these aren't gated by baseline.json,
// so it's only the per-`op` metrics that need to be preserved precisely.
merged.metrics[k] = v;
}
}
writeFileSync(output, `${JSON.stringify(merged, null, 2)}\n`);
console.log(`merged ${inputs.length} summaries → ${output}`);
View File
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
# Full k6 load-test runner.
# Starts postgres + OxiCloud server, seeds fixtures, runs k6 scenarios,
# compares results against baseline/load.json, tears everything down.
#
# Usage (from repo root):
# bash tests/load/run.sh
#
# Env overrides:
# BUILD_TARGET=release # prefer release build for accurate timings
# LOAD_DEPTH=8 # override seeder shape (otherwise read from test.env)
# LOAD_FANOUT=3
# LOAD_FILES_PER_LEAF=3
# LOAD_EXTRA_USERS=20
# LOAD_GROUP_DEPTH=3
# LOAD_GROUP_FANOUT=5
# K6_SUMMARY_OUT=path # explicit output path (default: tests/load/results/<ts>.json)
#
# Prerequisites: docker, cargo, k6 >= 0.46, node >= 18
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
COMMON="$REPO_ROOT/tests/common"
LOAD_DIR="$REPO_ROOT/tests/load"
# shellcheck source=test.env
source "$LOAD_DIR/test.env"
SERVER_PORT="${base_url##*:}"
log() { echo "[load] $*"; }
die() { echo "[load] ERROR: $*" >&2; exit 1; }
wait_for_http() {
local url="$1" timeout="${2:-120}"
local deadline=$(( $(date +%s) + timeout ))
until curl -sf "$url" >/dev/null 2>&1; do
[[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url"
sleep 1
done
}
command -v k6 >/dev/null 2>&1 || die "k6 is required (https://k6.io/docs/get-started/installation/)"
command -v node >/dev/null 2>&1 || die "node >= 18 is required for compare.mjs"
SERVER_PID=""
cleanup() {
if [[ -n "$SERVER_PID" ]]; then
log "Stopping OxiCloud server (pid $SERVER_PID)..."
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
bash "$COMMON/stop-db.sh" || true
}
trap cleanup EXIT
bash "$COMMON/spawn-db.sh"
# NOTE: do NOT run init-test-schema.sh here. The OxiCloud server applies
# sqlx migrations on startup; applying them via raw psql first leaves the
# server's _sqlx_migrations tracking table empty, which makes the second
# pass try to re-ALTER tables that already have the column (e.g. migration
# 20260507000000_session_family.sql), and the server panics on boot.
set -a
# shellcheck source=../common/server.env
source "$COMMON/server.env"
OXICLOUD_SERVER_PORT=$SERVER_PORT
OXICLOUD_STORAGE_PATH="$LOAD_DIR/storage"
set +a
rm -rf "$OXICLOUD_STORAGE_PATH"
mkdir -p "$OXICLOUD_STORAGE_PATH"
BUILD_TARGET="${BUILD_TARGET:-release}"
# Respect CARGO_TARGET_DIR for self-hosted runners that bind-mount target/
# outside the workspace (avoids actions/checkout EBUSY on the mount point).
TARGET_DIR="${CARGO_TARGET_DIR:-$REPO_ROOT/target}"
OXICLOUD_BIN="$TARGET_DIR/$BUILD_TARGET/oxicloud"
SEED_BIN="$TARGET_DIR/$BUILD_TARGET/load-seed"
# Build both bins in one invocation. `load_seed_bin` is an empty marker
# feature that gates the load-seed bin without affecting oxicloud's dep
# graph, so cargo plans a single workspace build and oxicloud compiles
# exactly once. (See Cargo.toml comments on `load_seed_bin`.)
if [[ ! -x "$OXICLOUD_BIN" || ! -x "$SEED_BIN" ]]; then
log "Building OxiCloud + load-seed ($BUILD_TARGET)..."
if [[ "$BUILD_TARGET" == "release" ]]; then
cargo build --release --features load_seed_bin --bin oxicloud --bin load-seed
else
cargo build --features load_seed_bin --bin oxicloud --bin load-seed
fi
fi
# Start the server FIRST so its sqlx::migrate! populates _sqlx_migrations
# against the clean DB. The seeder then runs against the migrated schema
# while the server is still alive (it issues plain INSERTs, no DDL).
log "Starting OxiCloud server ($BUILD_TARGET) on port $SERVER_PORT..."
"$OXICLOUD_BIN" &
SERVER_PID=$!
wait_for_http "$base_url/ready" 120
log "Server ready."
log "Seeding fixtures..."
DEPTH="${LOAD_DEPTH:-${load_depth:-5}}"
FANOUT="${LOAD_FANOUT:-${load_fanout:-4}}"
FILES_PER_LEAF="${LOAD_FILES_PER_LEAF:-${load_files_per_leaf:-3}}"
EXTRA_USERS="${LOAD_EXTRA_USERS:-${load_extra_users:-20}}"
GROUP_DEPTH="${LOAD_GROUP_DEPTH:-${load_group_depth:-3}}"
GROUP_FANOUT="${LOAD_GROUP_FANOUT:-${load_group_fanout:-5}}"
mkdir -p "$LOAD_DIR/results"
MANIFEST_PATH="$LOAD_DIR/results/seed-manifest.json"
"$SEED_BIN" \
--depth "$DEPTH" \
--fanout "$FANOUT" \
--files-per-leaf "$FILES_PER_LEAF" \
--extra-users "$EXTRA_USERS" \
--group-depth "$GROUP_DEPTH" \
--group-fanout "$GROUP_FANOUT" \
--password "${password:-TestPassword1!}" \
--manifest "$MANIFEST_PATH"
TS="$(date +%s)"
SUMMARY_OUT="${K6_SUMMARY_OUT:-$LOAD_DIR/results/run-$TS.json}"
export K6_BASE_URL="$base_url"
export K6_USERNAME="${username:-admin}"
export K6_PASSWORD="${password:-TestPassword1!}"
# k6 only accepts one script per invocation, so each scenario runs separately
# and we merge the summaries afterwards. Per-scenario summaries also make it
# easier to attribute regressions when looking at raw artifacts in CI.
SCENARIOS=(folder_cascade share_cascade_rebac subject_group_nested)
PARTIAL_SUMMARIES=()
K6_FAILED=0
for name in "${SCENARIOS[@]}"; do
partial="$LOAD_DIR/results/run-$TS-$name.json"
PARTIAL_SUMMARIES+=("$partial")
log "Running k6 scenario: $name"
# --summary-trend-stats forces p(99) into the summary export; k6's default
# only includes avg/min/med/max/p(90)/p(95), so without it baseline.p99 can
# never be baked or diffed.
k6 run \
--summary-export="$partial" \
--summary-trend-stats="avg,min,med,max,p(90),p(95),p(99)" \
--quiet \
"$LOAD_DIR/scenarios/$name.js" \
|| K6_FAILED=$?
done
log "Merging summaries -> $SUMMARY_OUT"
node "$LOAD_DIR/merge-summaries.mjs" "${PARTIAL_SUMMARIES[@]}" "$SUMMARY_OUT"
log "Comparing against baseline..."
COMPARE_RC=0
node "$LOAD_DIR/compare.mjs" "$SUMMARY_OUT" "$LOAD_DIR/baseline/load.json" || COMPARE_RC=$?
if [[ "$K6_FAILED" -ne 0 ]]; then
log "k6 reported threshold failures (exit $K6_FAILED)."
exit "$K6_FAILED"
fi
exit "$COMPARE_RC"
+60
View File
@@ -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 });
}
+64
View File
@@ -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 });
}
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Smoke (PR-tier) k6 load runner.
# Same shape as run.sh but: no seeder, only the smoke scenario, no regression
# diff. Goal is harness liveness - does the server still boot and does k6 still
# wire up - not perf gating.
#
# Usage (from repo root):
# bash tests/load/smoke.sh
#
# Env overrides:
# BUILD_TARGET=debug # debug is fine; smoke doesn't measure timings
#
# Prerequisites: docker, cargo, k6 >= 0.46
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
COMMON="$REPO_ROOT/tests/common"
LOAD_DIR="$REPO_ROOT/tests/load"
# shellcheck source=test.env
source "$LOAD_DIR/test.env"
SERVER_PORT="${base_url##*:}"
log() { echo "[load-smoke] $*"; }
die() { echo "[load-smoke] ERROR: $*" >&2; exit 1; }
wait_for_http() {
local url="$1" timeout="${2:-60}"
local deadline=$(( $(date +%s) + timeout ))
until curl -sf "$url" >/dev/null 2>&1; do
[[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url"
sleep 1
done
}
command -v k6 >/dev/null 2>&1 || die "k6 is required (https://k6.io/docs/get-started/installation/)"
SERVER_PID=""
cleanup() {
if [[ -n "$SERVER_PID" ]]; then
log "Stopping OxiCloud server (pid $SERVER_PID)..."
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
bash "$COMMON/stop-db.sh" || true
}
trap cleanup EXIT
bash "$COMMON/spawn-db.sh"
# Server applies sqlx migrations on startup; don't double-apply via psql here.
# See run.sh for the gory details.
set -a
# shellcheck source=../common/server.env
source "$COMMON/server.env"
OXICLOUD_SERVER_PORT=$SERVER_PORT
OXICLOUD_STORAGE_PATH="$LOAD_DIR/storage"
set +a
rm -rf "$OXICLOUD_STORAGE_PATH"
mkdir -p "$OXICLOUD_STORAGE_PATH"
BUILD_TARGET="${BUILD_TARGET:-debug}"
OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud"
if [[ -x "$OXICLOUD_BIN" ]]; then
log "Starting pre-built OxiCloud server ($BUILD_TARGET) on port $SERVER_PORT..."
"$OXICLOUD_BIN" &
else
log "Building and starting OxiCloud server ($BUILD_TARGET) on port $SERVER_PORT..."
cd "$REPO_ROOT"
if [[ "$BUILD_TARGET" == "release" ]]; then
cargo run --release &
else
cargo run &
fi
fi
SERVER_PID=$!
wait_for_http "$base_url/ready" 120
log "Server ready."
# Bootstrap the admin account via /api/setup (one-shot — disabled once an
# admin exists, mirrors tests/api/setup.hurl). Without this the smoke scenario
# would have no one to log in as.
log "Creating admin via /api/setup..."
SETUP_BODY=$(printf '{"username":"%s","email":"%s","password":"%s"}' \
"${username:-admin}" "${email:-admin@example.com}" "${password:-TestPassword1!}")
SETUP_STATUS=$(curl -sS -o /dev/null -w '%{http_code}' \
-X POST "$base_url/api/setup" \
-H 'Content-Type: application/json' \
-d "$SETUP_BODY")
if [[ "$SETUP_STATUS" != "201" ]]; then
die "/api/setup returned $SETUP_STATUS (expected 201)"
fi
export K6_BASE_URL="$base_url"
export K6_USERNAME="${username:-admin}"
export K6_PASSWORD="${password:-TestPassword1!}"
log "Running smoke scenario..."
k6 run \
--summary-trend-stats="avg,min,med,max,p(90),p(95),p(99)" \
--quiet \
"$LOAD_DIR/scenarios/smoke.js"
log "Smoke OK."
+31
View File
@@ -0,0 +1,31 @@
# Test credentials and base URL for tests/load/ — NOT real secrets.
# Mirrors tests/api/test.env shape; uses a different server port (8088)
# so it does not collide with api-test (8087).
base_url=http://localhost:8088
username=admin
email=admin@example.com
# gitguardian:ignore
password=TestPassword1!
# ── Seed sizing ─────────────────────────────────────────────────────────────
# Folder count is exponential: total folders per subtree ≈ fanout^depth.
# The seeder builds TWO subtrees (one for the user-grant scenario, one for the
# group-grant scenario), so multiply by 2 for the grand total. Each leaf folder
# then gets `files_per_leaf` rows.
#
# Examples (folders per subtree):
# depth=4 fanout=3 → 121 (seed ≈ 1 s)
# depth=5 fanout=4 → 1 365 (seed ≈ 10 s) ← default
# depth=6 fanout=3 → 1 093 (seed ≈ 10 s)
# depth=8 fanout=3 → 9 841 (seed ≈ 30 s)
# depth=8 fanout=5 → 488 281 (seed ≈ several minutes — exponential blow-up)
#
# Override per run with LOAD_DEPTH / LOAD_FANOUT / LOAD_FILES_PER_LEAF.
load_depth=10
load_fanout=3
load_files_per_leaf=3
load_extra_users=20
load_group_depth=3
load_group_fanout=5