Load Testing
AnvilBase ships a reusable k6 load-testing
suite under load/
that exercises every data-plane surface — REST, Auth, Storage, Queues, Cache —
under four canonical load shapes: smoke → throughput → stress → soak.
The suite is parameterised entirely by environment variables, so the same
scripts run against any deployment without edits: you point BASE_URL at a
dedicated test stack, supply the project id and keys, and pick a scenario.
Where to run this — read first
Never point this suite at a production deployment, and never run k6 on the same host as the system under test. Load testing exists to push a system to its limits — doing that to prod takes prod down, and doing it from the same box means the load generator and AnvilBase fight over the same CPU/RAM/NIC, so you measure the host, not AnvilBase. The numbers are then meaningless.
Run k6 from a separate machine (ideally a separate host/region) against a dedicated, prod-sized test deployment. The test plan below is a checklist you approve before the real run.
The suite at a glance
load/├── lib/│ ├── config.js # env → BASE_URL/PROJECT_ID/keys + URL & header helpers│ ├── scenarios.js # smoke|throughput|stress|soak → k6 options + thresholds│ └── checks.js # shared check() wrappers + per-surface Trend/Rate metrics├── rest-read.js # GET a table (anon / authenticated)├── rest-write.js # POST/PATCH a table (service_role)├── auth.js # signup + password-grant token├── storage.js # upload + download an object (service_role)├── queues.js # send + read + ack a message (service_role)├── mixed.js # weighted blended workload across surfaces└── run.sh # thin wrapper: ./run.sh <surface> <scenario>Each script targets the native data-plane shape
(/v1/<subsystem>/<project_id>/…) and authenticates with the Supabase-compatible
apikey + Authorization: Bearer headers (see
Data-plane API).
Scenarios
The scenario is selected with SCENARIO=smoke|throughput|stress|soak. Every
script shares one definition (load/lib/scenarios.js), so the methodology is
consistent across surfaces.
| Scenario | k6 executor | What it answers | Default shape |
|---|---|---|---|
| smoke | constant-vus | ”Does this endpoint work under k6 at all?” — a CI-safe sanity gate. | 1 VU, 30s |
| throughput | ramping-arrival-rate | ”What sustained RPS can I hold, and at what latency?” — an open model that holds a fixed arrival rate regardless of how the system responds. | ramp to RATE RPS, hold 3m |
| stress | ramping-arrival-rate (stepped up) | “Where’s the breaking point?” — ramps the arrival rate past the expected ceiling until thresholds break. Thresholds do not abort, so the run reaches the failure zone. | step to RATE peak, hold 2m |
| soak | constant-arrival-rate | ”Does it degrade or leak over time?” — moderate steady load for hours surfaces memory leaks, pool exhaustion, and latency drift a short run hides. | RATE RPS, 2h |
Why an arrival-rate (open) model for throughput/stress? A closed VU model throttles itself when the system slows (each VU waits for its response before sending the next), which hides saturation. An open model keeps offering the target rate, so a slowing system shows up as rising latency and a growing error rate — exactly the signal you want when finding a ceiling.
Tunables
Override any default without editing scripts:
| Var | Effect |
|---|---|
VUS | worker pool size / VU count |
DURATION | hold duration for the active scenario |
RATE | target requests/second for arrival-rate executors |
P95_MS / P99_MS | latency thresholds (default 500 / 1000 ms) |
ERR_RATE | max error rate threshold (default 0.01 = 1%) |
Configuration
| Var | Required | Example |
|---|---|---|
BASE_URL | yes | http://localhost:39001 or https://test.anvil.example |
PROJECT_ID | yes | p_abc123 |
ANON_KEY | for anon surfaces | anvilbase_anon_… |
SERVICE_ROLE_KEY | for write/storage/queue surfaces | anvilbase_service_role_… |
AUTH_EMAIL / AUTH_PASSWORD | auth token-grant only | a seeded test user |
TABLE BUCKET QUEUE CACHE_KEY | no | surface targets (sensible defaults) |
On a local compose stack the control plane maps host 39001 → container 3001,
so BASE_URL=http://localhost:39001. Behind Traefik on a test box it’s the
public HTTPS origin.
Seed schema
The rest-* and mixed scripts read/write a table (default load_test). Create
it on the test project first:
create table if not exists public.load_test ( id bigint generated always as identity primary key, label text, created_at timestamptz not null default now());-- For anon reads, disable RLS on this throwaway test table or add a permissive-- SELECT policy. This table lives on a THROWAWAY project — never your real data.storage.js and queues.js create their bucket / queue automatically (idempotent).
Running
Via the wrapper (load/run.sh <surface> <scenario>):
BASE_URL=https://test.anvil.example \PROJECT_ID=p_abc123 \ANON_KEY=anvilbase_anon_… \SERVICE_ROLE_KEY=anvilbase_service_role_… \ ./load/run.sh rest-read throughput
# stream raw samples to a file for later analysis... ./load/run.sh mixed stress -- --out json=mixed-stress.jsonOr call k6 directly:
k6 run -e SCENARIO=throughput \ -e BASE_URL=… -e PROJECT_ID=… -e ANON_KEY=… \ load/rest-read.jsReading the results
k6 prints a summary at the end of every run. The numbers that matter:
| Metric | What it tells you |
|---|---|
http_req_duration p(50)/p(95)/p(99) | latency. p95 is the headline SLO; p99 catches tail blow-ups. |
http_req_failed | error rate (non-2xx / transport errors). The first thing to break under stress. |
http_reqs / iterations (per second) | achieved throughput. Compare against the target RATE: a gap means the system couldn’t keep up. |
vus / vus_max | how many workers were needed to sustain the rate — climbing VUs at a flat rate signals the system is slowing. |
dropped_iterations | (arrival-rate executors) requests k6 couldn’t even start because the VU pool was exhausted — raise VUS/maxVUs or you’ve found a real ceiling. |
Per-surface Trends (rest_read_latency, auth_token_latency, …) | latency broken down by surface, so a blended mixed run shows which surface is the bottleneck. |
A ✓/✗ next to each threshold shows pass/fail. In stress runs, thresholds
are expected to fail at the top stages — the rate/VU level at which http_req_failed
crosses your ERR_RATE or http_req_duration p95 crosses P95_MS is the knee.
Thresholds
Defaults (tune to your box):
http_req_failed: rate < 0.01 (< 1% errors)http_req_duration: p(95) < 500ms, p(99) < 1000ms<surface>_latency: p(95) < per-surface budget (e.g. auth p95 < 1500ms)These are deliberately conservative starting points. Calibrate them against your dedicated box’s baseline (a smoke run) before trusting pass/fail.
Automated provisioning rig
For a reproducible, disposable rig you don’t have to wire by hand,
load/infra/
provisions a System-Under-Test box + a separate k6 generator on a private
network (OpenTofu/Terraform), deploys AnvilBase from source, seeds a throwaway
test project, and drives the matrix below — on Hetzner (hcloud) or
Infomaniak (OpenStack), so you can run the same experiment head-to-head and
compare (e.g. local NVMe vs networked Ceph storage). It honours the rules on this
page: the generator is always a separate box, the target is always a throwaway
project, and tofu destroy tears it all down. See
load/infra/README.md.
Dedicated-server test plan
A meaningful load test is a controlled experiment, not a one-off command. Treat the steps below as a checklist the operator approves before the real run.
1. Provision a dedicated test server
- Stand up a separate server sized to your intended production target. Document the assumptions so results are reproducible, e.g. a baseline of 8 vCPU / 16 GB RAM / NVMe SSD with ≥ 3k IOPS / 1 Gbps network. Storage throughput and Postgres connection limits matter as much as CPU.
- This host runs only AnvilBase — no k6, no other tenants. It is disposable: you will push it to failure.
- Record the exact spec (instance type, kernel, disk class) alongside the run.
2. Deploy AnvilBase + seed a test project
- Deploy via
docker composeon the dedicated host (see Docker Compose). Use the same image tags and config as production so the test reflects reality. - Create a throwaway test project and capture its
PROJECT_ID,anonandservice_rolekeys. - Apply the seed schema. For anon reads, disable RLS on the
throwaway
load_testtable (or add a permissive SELECT policy). - Run a smoke scenario against each surface to confirm wiring and capture a baseline latency before adding load.
3. Run k6 from a separate host
- Run k6 on a different machine — ideally a different host or region — so the generator never competes with the system under test. A small dedicated runner is enough; if you saturate the runner, scale it out or use k6 distributed mode, not a bigger box on the SUT.
- Confirm the network path between runner and SUT mirrors how real clients reach it (through Traefik / TLS), so you measure the real ingress.
4. Ramp methodology
Run in this order, one surface at a time, then mixed:
- smoke — sanity + baseline latency (1 VU).
- throughput — hold a target
RATEyou believe prod needs; confirm p95 and error rate stay within SLO. This validates the expected load. - stress — step the rate up past that target until
http_req_failedor the p95 threshold breaks. The level just before the break is the knee — your sustainable ceiling. - soak — hold a moderate rate (well under the knee) for hours; watch for latency drift, growing memory, or pool exhaustion.
5. Metrics to capture
On the k6 side (per surface and for the blended mixed run):
- Achieved RPS vs target, and the RPS at which p95 < your SLO holds.
- The rate/VU level where the error rate begins to climb (onset, not just the final number).
- p95 / p99 latency curves as the rate increases.
On the server side (scrape /metrics, see Monitoring, plus
host metrics):
- CPU and memory saturation — which resource tops out first?
- Postgres connections / pool exhaustion — the REST→SQL per-project pool,
Supavisor,
dropped_iterationson the k6 side, and anydb_project_poolsgauge plateauing. - Disk IOPS / latency (storage + Postgres WAL) and network throughput.
6. Interpreting the breaking point
- The knee is where added load stops buying throughput: RPS flattens (or falls) while latency and error rate climb. That flat-RPS-rising-latency inflection is your sustainable ceiling — quote it as “N RPS at p95 < X ms”.
- Cross-reference the knee with server metrics to name the bottleneck resource (CPU? Postgres connections? disk IO?). That’s what you scale next — see Scaling.
- Re-run after each change to confirm the ceiling moved, and keep the runner, scenario, and server spec constant so results stay comparable.
The suite generates real traffic and, for write surfaces, real rows/objects/messages on the test project. Reset or recreate the throwaway project between full runs so a prior run’s data doesn’t skew the next. Never run write or stress scenarios against a project you care about.