chore(load): start implementation of load tests
initial test from Ed's nuc:
metric pctl baseline current delta status
-----------------------------------------------------------------------------------------------
folder_cascade.list_depth1 p50 0.3ms 0.3ms -6.3% ok
folder_cascade.list_depth1 p95 2.3ms 0.5ms -75.7% ok
folder_cascade.list_depth1 p99 4.7ms 2.5ms -48.1% ok
folder_cascade.list_depth4 p50 0.4ms 0.3ms -10.0% ok
folder_cascade.list_depth4 p95 0.9ms 0.6ms -31.2% ok
folder_cascade.list_depth4 p99 2.4ms 1.0ms -56.7% ok
folder_cascade.list_depth8 p50 0.3ms 0.3ms -8.8% ok
folder_cascade.list_depth8 p95 0.6ms 0.5ms -22.2% ok
folder_cascade.list_depth8 p99 1.9ms 0.5ms -71.5% ok
folder_cascade.list_depth_deep p50 0.3ms 0.3ms -5.0% ok
folder_cascade.list_depth_deep p95 0.6ms 0.5ms -18.6% ok
folder_cascade.list_depth_deep p99 2.0ms 0.7ms -67.2% ok
share_cascade_rebac.list_grants p50 0.4ms 0.3ms -27.6% ok
share_cascade_rebac.list_grants p95 1.2ms 0.5ms -57.0% ok
share_cascade_rebac.list_grants p99 1.7ms 1.1ms -36.7% ok
share_cascade_rebac.fetch_as_grantee_depth1 p50 0.5ms 0.5ms -11.1% ok
share_cascade_rebac.fetch_as_grantee_depth1 p95 1.1ms 0.7ms -41.2% ok
share_cascade_rebac.fetch_as_grantee_depth1 p99 3.0ms 1.3ms -58.3% ok
share_cascade_rebac.fetch_as_grantee_depth4 p50 0.5ms 0.5ms -13.7% ok
share_cascade_rebac.fetch_as_grantee_depth4 p95 1.4ms 0.7ms -50.5% ok
share_cascade_rebac.fetch_as_grantee_depth4 p99 2.2ms 1.1ms -49.3% ok
share_cascade_rebac.fetch_as_grantee_depth8 p50 0.5ms 0.4ms -16.0% ok
share_cascade_rebac.fetch_as_grantee_depth8 p95 0.9ms 0.7ms -26.1% ok
share_cascade_rebac.fetch_as_grantee_depth8 p99 1.5ms 0.9ms -40.1% ok
share_cascade_rebac.fetch_as_grantee_depth_deep p50 0.5ms 0.4ms -17.3% ok
share_cascade_rebac.fetch_as_grantee_depth_deep p95 1.0ms 0.7ms -31.2% ok
share_cascade_rebac.fetch_as_grantee_depth_deep p99 1.6ms 0.8ms -49.4% ok
subject_group_nested.fetch_as_member_depth1 p50 0.5ms 0.4ms -8.4% ok
subject_group_nested.fetch_as_member_depth1 p95 0.6ms 0.6ms -10.2% ok
subject_group_nested.fetch_as_member_depth1 p99 1.4ms 0.6ms -55.2% ok
subject_group_nested.fetch_as_member_depth4 p50 0.5ms 0.5ms -7.9% ok
subject_group_nested.fetch_as_member_depth4 p95 0.6ms 0.6ms -4.0% ok
subject_group_nested.fetch_as_member_depth4 p99 0.7ms 0.6ms -2.2% ok
subject_group_nested.fetch_as_member_depth8 p50 0.5ms 0.4ms -8.9% ok
subject_group_nested.fetch_as_member_depth8 p95 0.5ms 0.6ms +7.1% ok
subject_group_nested.fetch_as_member_depth8 p99 0.6ms 0.7ms +10.3% ok
subject_group_nested.fetch_as_member_depth_deep p50 0.5ms 0.4ms -7.6% ok
subject_group_nested.fetch_as_member_depth_deep p95 0.6ms 0.5ms -11.9% ok
subject_group_nested.fetch_as_member_depth_deep p99 0.6ms 0.7ms +8.9% ok
This commit is contained in:
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
exit 1
|
||||
}
|
||||
#
|
||||
# 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
@@ -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.
|
||||
|
||||
@@ -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>');
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,38 @@
|
||||
// Login helper for OxiCloud k6 load tests.
|
||||
// Returns the JWT access token from POST /api/auth/login.
|
||||
|
||||
import { check, fail } from 'k6';
|
||||
import http from 'k6/http';
|
||||
import { BASE } from './http.js';
|
||||
|
||||
/**
|
||||
* Log in via the public auth endpoint and return the bearer token.
|
||||
*
|
||||
* `body` shape matches `auth_handler.rs::login` — `{username, password}`.
|
||||
* The response carries `access_token` plus refresh state we don't need here.
|
||||
*
|
||||
* The `op` tag should be scenario-qualified (e.g. 'smoke.login') so the
|
||||
* recorded metric matches the corresponding key in baseline/baseline.json.
|
||||
* Bare 'login' is fine for ad-hoc scripts that aren't regression-gated.
|
||||
*
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
* @param {string} [op='login']
|
||||
* @returns {string}
|
||||
*/
|
||||
export function login(username, password, op = 'login') {
|
||||
const res = http.post(
|
||||
`${BASE}/api/auth/login`,
|
||||
JSON.stringify({ username, password }),
|
||||
{ headers: { 'Content-Type': 'application/json' }, tags: { op } },
|
||||
);
|
||||
|
||||
const ok = check(res, {
|
||||
'login 200': (r) => r.status === 200,
|
||||
'login has access_token': (r) => !!r.json('access_token'),
|
||||
});
|
||||
if (!ok) {
|
||||
fail(`login failed for ${username}: status=${res.status}, body=${res.body}`);
|
||||
}
|
||||
return res.json('access_token');
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Shared HTTP helpers for OxiCloud k6 load tests.
|
||||
// Centralises the base URL and adds the Bearer token + JSON Content-Type
|
||||
// headers expected by every protected endpoint.
|
||||
|
||||
import http from 'k6/http';
|
||||
|
||||
/**
|
||||
* Base URL of the OxiCloud server under test.
|
||||
* Read from K6_BASE_URL (set by run.sh), defaulting to the load-suite port.
|
||||
*/
|
||||
export const BASE = __ENV.K6_BASE_URL || 'http://localhost:8088';
|
||||
|
||||
/**
|
||||
* Build a request params object with auth + JSON headers and a `tag` so
|
||||
* the response's metrics are isolated under `<scenario>.<op>`.
|
||||
*
|
||||
* @param {string} token Bearer token from auth.login()
|
||||
* @param {string} op Metric tag, e.g. 'folder_cascade.list_depth8'
|
||||
*/
|
||||
export function jsonParams(token, op) {
|
||||
return {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
tags: { op },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as jsonParams but without a body content-type — for GETs and DELETEs.
|
||||
*/
|
||||
export function authParams(token, op) {
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
tags: { op },
|
||||
};
|
||||
}
|
||||
|
||||
export const httpClient = http;
|
||||
@@ -0,0 +1,76 @@
|
||||
// Baseline-driven thresholds and shared helpers for k6 scenarios.
|
||||
//
|
||||
// Each scenario tags its requests with `op: '<scenario>.<op>'` (see http.js
|
||||
// `jsonParams` / `authParams`). The baseline file lists one entry per
|
||||
// `<scenario>.<op>` with p50/p95/p99 and a tolerance percentage; we convert
|
||||
// every relevant entry to a k6 threshold so the run fails directly on
|
||||
// regression — `compare.mjs` then produces the human-readable diff.
|
||||
|
||||
// Resolve relative to THIS file (lib/metrics.js), not the importer. Future
|
||||
// k6 versions will align open()'s path-resolution with ES module semantics;
|
||||
// using import.meta.resolve() future-proofs against the warning logged by
|
||||
// k6 ≥ 0.50.
|
||||
const LOAD_BASELINE_PATH = import.meta.resolve('../baseline/load.json');
|
||||
const SMOKE_BASELINE_PATH = import.meta.resolve('../baseline/smoke.json');
|
||||
const MANIFEST_PATH = import.meta.resolve('../results/seed-manifest.json');
|
||||
|
||||
// Eagerly load both baseline files at module init (open() is only allowed
|
||||
// in init context). Keys are disjoint by scenario prefix, so merging the
|
||||
// two maps is safe; `thresholdsFromBaseline(prefix)` filters from the union.
|
||||
const BASELINE = {
|
||||
...JSON.parse(open(LOAD_BASELINE_PATH)),
|
||||
...JSON.parse(open(SMOKE_BASELINE_PATH)),
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the merged baseline (load + smoke) — convenient for tooling that
|
||||
* wants to inspect everything; scenarios should use `thresholdsFromBaseline`.
|
||||
*/
|
||||
export function loadBaseline() {
|
||||
return BASELINE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a k6 `thresholds` object from the baseline files, filtered to the
|
||||
* given scenario prefix (e.g. 'folder_cascade').
|
||||
*
|
||||
* Result shape (k6 expects metric-name → threshold-expression-array):
|
||||
* {
|
||||
* 'http_req_duration{op:folder_cascade.list_depth8}': [
|
||||
* 'p(95)<49.5', // 45 * (1 + 10/100)
|
||||
* 'p(99)<88.0',
|
||||
* ],
|
||||
* }
|
||||
*
|
||||
* @param {string} scenarioPrefix
|
||||
*/
|
||||
export function thresholdsFromBaseline(scenarioPrefix) {
|
||||
const baseline = BASELINE;
|
||||
const thresholds = {};
|
||||
for (const [key, val] of Object.entries(baseline)) {
|
||||
if (key.startsWith('_')) continue; // skip _comment etc.
|
||||
if (!key.startsWith(`${scenarioPrefix}.`)) continue;
|
||||
const tol = (val.tolerance_pct || 10) / 100;
|
||||
// Mirror compare.mjs: use the larger of the %-based limit and the
|
||||
// absolute-floor limit (baseline + min_delta_ms). Below sub-millisecond
|
||||
// scale, the % rule alone fires on noise — the floor stops that.
|
||||
const minDelta = val.min_delta_ms ?? 0.5;
|
||||
const p95Limit = Math.max(val.p95 * (1 + tol), val.p95 + minDelta);
|
||||
const p99Limit = Math.max(val.p99 * (1 + tol), val.p99 + minDelta);
|
||||
thresholds[`http_req_duration{op:${key}}`] = [
|
||||
`p(95)<${p95Limit.toFixed(2)}`,
|
||||
`p(99)<${p99Limit.toFixed(2)}`,
|
||||
];
|
||||
}
|
||||
// `abortOnFail: false` (default) keeps the run going so we collect all
|
||||
// regressions in one pass; the non-zero exit at the end still fails CI.
|
||||
return thresholds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the seed manifest written by `cargo run --bin load-seed`.
|
||||
*/
|
||||
export function loadManifest() {
|
||||
const raw = open(MANIFEST_PATH);
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
@@ -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}`);
|
||||
Executable
+166
@@ -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"
|
||||
@@ -0,0 +1,60 @@
|
||||
// folder_cascade.js — measures the read-path of `GET /api/folders/{id}/resources?resource_types=folder`
|
||||
// at four depths against a pre-seeded tree. Captures how listing cost scales
|
||||
// with ltree depth (cf. `idx_folders_lpath` GiST index). Mid-depth samples
|
||||
// (depth4, depth8) fall back to `deepest` when the seeded tree is shallower
|
||||
// than that depth — see load-seed.rs::build_subtree.
|
||||
|
||||
import { check } from 'k6';
|
||||
import http from 'k6/http';
|
||||
import { BASE, authParams } from '../lib/http.js';
|
||||
import { login } from '../lib/auth.js';
|
||||
import { thresholdsFromBaseline, loadManifest } from '../lib/metrics.js';
|
||||
|
||||
const manifest = loadManifest();
|
||||
|
||||
export const options = {
|
||||
vus: 1,
|
||||
// 100 iterations gives p99 statistical meaning: at N=100, p99 = position 99,
|
||||
// representing one bad sample out of a hundred — a real percentile rather
|
||||
// than the worst-of-the-batch. At N=25 (the typical k6 example default),
|
||||
// p99 ≈ max, dominated by single-sample kernel/scheduler noise.
|
||||
iterations: 100,
|
||||
thresholds: thresholdsFromBaseline('folder_cascade'),
|
||||
};
|
||||
|
||||
// One per-VU login. K6 calls setup() once across the whole test, default()
|
||||
// `iterations` times per VU. Logging in inside default() would dominate the
|
||||
// per-iter cost; we hand the token down through the `data` arg.
|
||||
export function setup() {
|
||||
const token = login(manifest.admin.username, manifest.admin.password);
|
||||
return { token };
|
||||
}
|
||||
|
||||
export default function (data) {
|
||||
const { token } = data;
|
||||
const t = manifest.shared_subtree;
|
||||
|
||||
const r1 = http.get(
|
||||
`${BASE}/api/folders/${t.root}/resources?resource_types=folder`,
|
||||
authParams(token, 'folder_cascade.list_depth1'),
|
||||
);
|
||||
check(r1, { 'list depth1 200': (r) => r.status === 200 });
|
||||
|
||||
const r4 = http.get(
|
||||
`${BASE}/api/folders/${t.depth4}/resources?resource_types=folder`,
|
||||
authParams(token, 'folder_cascade.list_depth4'),
|
||||
);
|
||||
check(r4, { 'list depth4 200': (r) => r.status === 200 });
|
||||
|
||||
const r8 = http.get(
|
||||
`${BASE}/api/folders/${t.depth8}/resources?resource_types=folder`,
|
||||
authParams(token, 'folder_cascade.list_depth8'),
|
||||
);
|
||||
check(r8, { 'list depth8 200': (r) => r.status === 200 });
|
||||
|
||||
const rD = http.get(
|
||||
`${BASE}/api/folders/${t.deepest}/resources?resource_types=folder`,
|
||||
authParams(token, 'folder_cascade.list_depth_deep'),
|
||||
);
|
||||
check(rD, { 'list deepest 200': (r) => r.status === 200 });
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// share_cascade_rebac.js — measures ReBAC AuthZ cascade via a direct user
|
||||
// grant. The seeder grants `read` on shared_subtree.root (depth 0) to the
|
||||
// grantee user; this scenario times how long the grantee takes to fetch
|
||||
// folders at varying depths inside that subtree.
|
||||
//
|
||||
// The AuthZ recursive-CTE in `pg_acl_engine` walks up the ancestor chain
|
||||
// from the requested folder until it finds a matching grant (or runs out
|
||||
// of ancestors). The deeper the requested folder, the more ancestors the
|
||||
// CTE has to traverse before hitting the grant on the root:
|
||||
//
|
||||
// fetch_at_grant_root → 0 ancestors walked (grant is right here)
|
||||
// fetch_as_grantee_depth4 → 4 ancestors walked
|
||||
// fetch_as_grantee_depth8 → 8 ancestors walked
|
||||
// fetch_as_grantee_depth_deep → `load_depth` ancestors walked
|
||||
//
|
||||
// A regression in AuthZ cost should show up as the cascade-depth curve
|
||||
// flattening or steepening — that's the value of intermediate samples.
|
||||
|
||||
import { check } from 'k6';
|
||||
import http from 'k6/http';
|
||||
import { BASE, authParams } from '../lib/http.js';
|
||||
import { login } from '../lib/auth.js';
|
||||
import { thresholdsFromBaseline, loadManifest } from '../lib/metrics.js';
|
||||
|
||||
const manifest = loadManifest();
|
||||
|
||||
export const options = {
|
||||
vus: 1,
|
||||
// See folder_cascade.js for the iteration-count rationale: N=100 makes
|
||||
// p99 a real percentile instead of the worst-of-the-batch.
|
||||
iterations: 100,
|
||||
thresholds: thresholdsFromBaseline('share_cascade_rebac'),
|
||||
};
|
||||
|
||||
export function setup() {
|
||||
const adminToken = login(manifest.admin.username, manifest.admin.password);
|
||||
const granteeToken = login(manifest.grantee.username, manifest.grantee.password);
|
||||
return { adminToken, granteeToken };
|
||||
}
|
||||
|
||||
export default function (data) {
|
||||
const { adminToken, granteeToken } = data;
|
||||
const t = manifest.shared_subtree;
|
||||
|
||||
// List grants on the granted folder (admin only).
|
||||
const grantsRes = http.get(
|
||||
`${BASE}/api/grants?resource_type=folder&resource_id=${t.root}`,
|
||||
authParams(adminToken, 'share_cascade_rebac.list_grants'),
|
||||
);
|
||||
check(grantsRes, { 'list grants 200': (r) => r.status === 200 });
|
||||
|
||||
// Baseline: grantee fetches the folder the grant is directly on. AuthZ
|
||||
// finds the matching grant on the first row of the CTE; zero ancestors
|
||||
// walked. This metric measures the constant overhead of an authorized
|
||||
// request — moves to the right of this metric is "cascade cost."
|
||||
const d1 = http.get(
|
||||
`${BASE}/api/folders/${t.root}/resources?resource_types=folder`,
|
||||
authParams(granteeToken, 'share_cascade_rebac.fetch_as_grantee_depth1'),
|
||||
);
|
||||
check(d1, { 'fetch root 200': (r) => r.status === 200 });
|
||||
|
||||
// Grantee fetches a mid-tree folder. AuthZ walks 4 ancestors before
|
||||
// hitting the grant.
|
||||
const d4 = http.get(
|
||||
`${BASE}/api/folders/${t.depth4}/resources?resource_types=folder`,
|
||||
authParams(granteeToken, 'share_cascade_rebac.fetch_as_grantee_depth4'),
|
||||
);
|
||||
check(d4, { 'fetch depth4 200': (r) => r.status === 200 });
|
||||
|
||||
// Grantee fetches a folder 8 levels under the granted root. AuthZ
|
||||
// walks 8 ancestors.
|
||||
const d8 = http.get(
|
||||
`${BASE}/api/folders/${t.depth8}/resources?resource_types=folder`,
|
||||
authParams(granteeToken, 'share_cascade_rebac.fetch_as_grantee_depth8'),
|
||||
);
|
||||
check(d8, { 'fetch depth8 200': (r) => r.status === 200 });
|
||||
|
||||
// Grantee fetches the deepest descendant — full-length cascade.
|
||||
const dD = http.get(
|
||||
`${BASE}/api/folders/${t.deepest}/resources?resource_types=folder`,
|
||||
authParams(granteeToken, 'share_cascade_rebac.fetch_as_grantee_depth_deep'),
|
||||
);
|
||||
check(dD, { 'fetch deepest 200': (r) => r.status === 200 });
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// smoke.js — fast PR-tier check. Verifies the load harness still builds and
|
||||
// the server boots, exercising one happy-path of every HTTP verb the long
|
||||
// scenarios use. No regression gate; run.sh's `smoke.sh` skips compare.mjs.
|
||||
|
||||
import { check, sleep } from 'k6';
|
||||
import http from 'k6/http';
|
||||
import { BASE, jsonParams, authParams } from '../lib/http.js';
|
||||
import { login } from '../lib/auth.js';
|
||||
import { thresholdsFromBaseline } from '../lib/metrics.js';
|
||||
|
||||
export const options = {
|
||||
vus: 1,
|
||||
iterations: 1,
|
||||
thresholds: thresholdsFromBaseline('smoke'),
|
||||
};
|
||||
|
||||
// Admin creds match tests/load/test.env defaults — overridable via env.
|
||||
const USERNAME = __ENV.K6_USERNAME || 'admin';
|
||||
const PASSWORD = __ENV.K6_PASSWORD || 'TestPassword1!';
|
||||
|
||||
export default function () {
|
||||
// 1. login
|
||||
const token = login(USERNAME, PASSWORD, 'smoke.login');
|
||||
|
||||
// 2. create a scratch folder at root
|
||||
const folderName = `smoke_${Date.now()}`;
|
||||
const createRes = http.post(
|
||||
`${BASE}/api/folders`,
|
||||
JSON.stringify({ name: folderName }),
|
||||
jsonParams(token, 'smoke.create_folder'),
|
||||
);
|
||||
check(createRes, { 'create folder 200/201': (r) => r.status === 200 || r.status === 201 });
|
||||
const folderId = createRes.json('id');
|
||||
|
||||
// 3. upload a tiny file via multipart
|
||||
const fileData = http.file('hello\n', 'smoke.txt', 'text/plain');
|
||||
const uploadRes = http.post(
|
||||
`${BASE}/api/files/upload`,
|
||||
{ folder_id: folderId, file: fileData },
|
||||
{ headers: { Authorization: `Bearer ${token}` }, tags: { op: 'smoke.upload_tiny' } },
|
||||
);
|
||||
const uploadOk = check(uploadRes, {
|
||||
'upload 200/201': (r) => r.status === 200 || r.status === 201,
|
||||
});
|
||||
if (!uploadOk) {
|
||||
console.error(
|
||||
`upload failed: status=${uploadRes.status}, body=${uploadRes.body}, headers=${JSON.stringify(uploadRes.headers)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. list root folders for this user
|
||||
const listRes = http.get(`${BASE}/api/folders`, authParams(token, 'smoke.list_root'));
|
||||
check(listRes, { 'list root 200': (r) => r.status === 200 });
|
||||
|
||||
// 5. delete the scratch folder
|
||||
const delRes = http.del(
|
||||
`${BASE}/api/folders/${folderId}`,
|
||||
null,
|
||||
authParams(token, 'smoke.delete_folder'),
|
||||
);
|
||||
check(delRes, { 'delete 200/204': (r) => r.status === 200 || r.status === 204 });
|
||||
|
||||
sleep(0.1);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// subject_group_nested.js — measures the worst-case AuthZ path: a user who
|
||||
// is a direct member of the *innermost* group of a depth-N nested chain, and
|
||||
// the grant is on the *outermost* group. Every AuthZ check has to expand the
|
||||
// chain transitively. Pairs with share_cascade_rebac.js to attribute regressions
|
||||
// to either the resource side (folder cascade) or the subject side (group
|
||||
// expansion).
|
||||
//
|
||||
// Same depth-gradient idea as share_cascade_rebac: measure AuthZ cost at
|
||||
// depth 1 / 4 / 8 / deep so we can see WHERE in the chain a regression
|
||||
// lands. The subject-side expansion (group_member → leaf → mid → root group
|
||||
// → grant) runs once per request regardless of folder depth, but the
|
||||
// resource-side cascade (folder ancestors walked to find the grant) grows
|
||||
// linearly with depth — so depth4/depth8/deep moving together while depth1
|
||||
// stays flat would point at the resource side; all four moving together
|
||||
// would point at the group-expansion path.
|
||||
|
||||
import { check } from 'k6';
|
||||
import http from 'k6/http';
|
||||
import { BASE, authParams } from '../lib/http.js';
|
||||
import { login } from '../lib/auth.js';
|
||||
import { thresholdsFromBaseline, loadManifest } from '../lib/metrics.js';
|
||||
|
||||
const manifest = loadManifest();
|
||||
|
||||
export const options = {
|
||||
vus: 1,
|
||||
// See folder_cascade.js for the iteration-count rationale: N=100 makes
|
||||
// p99 a real percentile instead of the worst-of-the-batch.
|
||||
iterations: 100,
|
||||
thresholds: thresholdsFromBaseline('subject_group_nested'),
|
||||
};
|
||||
|
||||
export function setup() {
|
||||
const memberToken = login(manifest.group_member.username, manifest.group_member.password);
|
||||
return { memberToken };
|
||||
}
|
||||
|
||||
export default function (data) {
|
||||
const { memberToken } = data;
|
||||
const t = manifest.group_subtree;
|
||||
|
||||
// Baseline: group member fetches the folder the grant is directly on.
|
||||
// Subject-side expansion runs (user → leaf → mid → root group → grant);
|
||||
// resource-side walk is zero ancestors. Captures the constant subject-
|
||||
// expansion overhead.
|
||||
const d1 = http.get(
|
||||
`${BASE}/api/folders/${t.root}/resources?resource_types=folder`,
|
||||
authParams(memberToken, 'subject_group_nested.fetch_as_member_depth1'),
|
||||
);
|
||||
check(d1, { 'fetch root 200': (r) => r.status === 200 });
|
||||
|
||||
// Mid-tree: 4 folder ancestors walked + subject expansion.
|
||||
const d4 = http.get(
|
||||
`${BASE}/api/folders/${t.depth4}/resources?resource_types=folder`,
|
||||
authParams(memberToken, 'subject_group_nested.fetch_as_member_depth4'),
|
||||
);
|
||||
check(d4, { 'fetch depth4 200': (r) => r.status === 200 });
|
||||
|
||||
// 8 folder ancestors walked + subject expansion.
|
||||
const d8 = http.get(
|
||||
`${BASE}/api/folders/${t.depth8}/resources?resource_types=folder`,
|
||||
authParams(memberToken, 'subject_group_nested.fetch_as_member_depth8'),
|
||||
);
|
||||
check(d8, { 'fetch depth8 200': (r) => r.status === 200 });
|
||||
|
||||
// Worst case: full-depth folder cascade × full-chain subject expansion.
|
||||
// group_member → leaf group → mid group → root group → grant → folder root → deepest.
|
||||
const dD = http.get(
|
||||
`${BASE}/api/folders/${t.deepest}/resources?resource_types=folder`,
|
||||
authParams(memberToken, 'subject_group_nested.fetch_as_member_depth_deep'),
|
||||
);
|
||||
check(dD, { 'fetch deepest 200': (r) => r.status === 200 });
|
||||
}
|
||||
Executable
+108
@@ -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."
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user