mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
264 lines
7.2 KiB
Markdown
264 lines
7.2 KiB
Markdown
# Load Testing Guide
|
|
|
|
This guide explains how to profile and optimize slow routes in sendou.ink.
|
|
|
|
## Prerequisites
|
|
|
|
Install autocannon globally:
|
|
|
|
```bash
|
|
npm install -g autocannon
|
|
```
|
|
|
|
## Step 1: Identify Slow Routes
|
|
|
|
### Data URLs vs Full Routes
|
|
|
|
When benchmarking a route like `/leaderboards`, the response includes server-side rendering (HTML generation). To isolate just the data loader performance, use React Router 7's data URLs:
|
|
|
|
```bash
|
|
# Full route (includes SSR)
|
|
autocannon -c 10 -d 10 http://localhost:301/leaderboards
|
|
|
|
# Data only (loader function only)
|
|
autocannon -c 10 -d 10 "http://localhost:301/leaderboards.data?_routes=features%2Fleaderboards%2Froutes%2Fleaderboards"
|
|
```
|
|
|
|
The data URL format is: `{route}.data?_routes={encoded_route_path}`
|
|
|
|
To discover the exact data URL for a route, open Chrome DevTools Network tab, navigate to the page, then filter by "Fetch/XHR" - you'll see the `.data` requests made during client-side navigation.
|
|
|
|
### Running benchmarks
|
|
|
|
Use autocannon to benchmark endpoints:
|
|
|
|
```bash
|
|
# Basic benchmark (10 connections, 10 seconds)
|
|
autocannon -c 10 -d 10 http://localhost:301/leaderboards
|
|
|
|
# With more connections to simulate load
|
|
autocannon -c 50 -d 30 http://localhost:301/leaderboards
|
|
|
|
# Single request timing
|
|
curl -s -o /dev/null -w "Time: %{time_total}s\n" "http://localhost:301/leaderboards"
|
|
```
|
|
|
|
### Reading autocannon output
|
|
|
|
```
|
|
┌─────────┬────────┬─────────┬─────────┬─────────┬────────────┬────────────┬─────────┐
|
|
│ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │
|
|
├─────────┼────────┼─────────┼─────────┼─────────┼────────────┼────────────┼─────────┤
|
|
│ Latency │ 826 ms │ 1478 ms │ 7674 ms │ 8985 ms │ 1989.09 ms │ 1770.75 ms │ 8985 ms │
|
|
└─────────┴────────┴─────────┴─────────┴─────────┴────────────┴────────────┴─────────┘
|
|
```
|
|
|
|
- **50%** (median): Half of requests are faster than this
|
|
- **97.5%/99%**: Tail latency - worst case for most users
|
|
- **Avg**: Average response time
|
|
- **Max**: Slowest single request
|
|
|
|
Target: median < 100ms, 99th percentile < 500ms
|
|
|
|
## Step 2: Profile SQL Queries
|
|
|
|
Create a profiling script to test queries directly against the prod database:
|
|
|
|
```typescript
|
|
// profile-query.ts
|
|
import Database from "better-sqlite3";
|
|
|
|
const db = new Database("db-prod.sqlite3");
|
|
|
|
function timeQuery(name: string, fn: () => unknown) {
|
|
const runs: number[] = [];
|
|
let result: unknown;
|
|
|
|
// Run 5 times for consistent measurement
|
|
for (let i = 0; i < 5; i++) {
|
|
const start = performance.now();
|
|
result = fn();
|
|
runs.push(performance.now() - start);
|
|
}
|
|
|
|
const avg = runs.reduce((a, b) => a + b, 0) / runs.length;
|
|
const rowCount = Array.isArray(result) ? result.length : "N/A";
|
|
console.log(`${name}: avg ${avg.toFixed(2)}ms (${rowCount} rows)`);
|
|
return { avg, result };
|
|
}
|
|
|
|
// Example: Test a query
|
|
const stm = db.prepare(`
|
|
SELECT * FROM "User" WHERE "id" = @id
|
|
`);
|
|
|
|
timeQuery("User lookup", () => stm.all({ id: 1 }));
|
|
|
|
db.close();
|
|
```
|
|
|
|
Run with:
|
|
|
|
```bash
|
|
npx tsx profile-query.ts
|
|
```
|
|
|
|
## Step 3: Analyze Query Plans
|
|
|
|
Use `EXPLAIN QUERY PLAN` to understand how SQLite executes queries:
|
|
|
|
```typescript
|
|
const plan = db.prepare(`
|
|
EXPLAIN QUERY PLAN
|
|
SELECT * FROM "User"
|
|
JOIN "Skill" ON "Skill"."userId" = "User"."id"
|
|
WHERE "Skill"."season" = @season
|
|
`).all({ season: 10 });
|
|
|
|
console.log(JSON.stringify(plan, null, 2));
|
|
```
|
|
|
|
### Reading query plans
|
|
|
|
- **SCAN**: Full table scan (slow for large tables)
|
|
- **SEARCH USING INDEX**: Index lookup (fast)
|
|
- **USE TEMP B-TREE**: Temporary sorting/grouping needed
|
|
|
|
## Step 4: Check Indexes
|
|
|
|
```typescript
|
|
// List indexes on a table
|
|
const indexes = db.prepare(`PRAGMA index_list("Skill")`).all();
|
|
console.log("Indexes:", JSON.stringify(indexes, null, 2));
|
|
|
|
// Get columns in an index
|
|
for (const index of indexes) {
|
|
const info = db.prepare(`PRAGMA index_info("${index.name}")`).all();
|
|
console.log(`${index.name}:`, JSON.stringify(info, null, 2));
|
|
}
|
|
|
|
// Table row counts
|
|
const count = db.prepare(`SELECT COUNT(*) as count FROM "Skill"`).get();
|
|
console.log(`Skill rows: ${count.count}`);
|
|
```
|
|
|
|
## Common Optimizations
|
|
|
|
### 1. LEFT JOIN → INNER JOIN
|
|
|
|
If your `WHERE` clause filters on the joined table, `LEFT JOIN` is unnecessary and prevents index usage:
|
|
|
|
```sql
|
|
-- Slow: scans entire ReportedWeapon table
|
|
SELECT * FROM "ReportedWeapon"
|
|
LEFT JOIN "GroupMatch" ON ...
|
|
WHERE "GroupMatch"."createdAt" > @date
|
|
|
|
-- Fast: uses index on createdAt
|
|
SELECT * FROM "ReportedWeapon"
|
|
INNER JOIN "GroupMatch" ON ...
|
|
WHERE "GroupMatch"."createdAt" > @date
|
|
```
|
|
|
|
### 2. Add Missing Indexes
|
|
|
|
If query plan shows `SCAN` on a large table with a `WHERE` clause, add an index:
|
|
|
|
```sql
|
|
CREATE INDEX skill_season ON "Skill"("season");
|
|
```
|
|
|
|
### 3. Avoid ORDER BY in CTEs
|
|
|
|
```sql
|
|
-- Slow: sorts intermediate results
|
|
WITH q1 AS (
|
|
SELECT ... ORDER BY count DESC
|
|
)
|
|
SELECT ... FROM q1
|
|
|
|
-- Fast: only sort final results
|
|
WITH q1 AS (
|
|
SELECT ...
|
|
)
|
|
SELECT ... FROM q1 ORDER BY count DESC
|
|
```
|
|
|
|
### 4. Use HAVING Instead of Subquery Filter
|
|
|
|
```sql
|
|
-- Filter aggregates directly
|
|
SELECT "userId", count(*) as cnt
|
|
FROM "ReportedWeapon"
|
|
GROUP BY "userId"
|
|
HAVING count(*) >= 7
|
|
```
|
|
|
|
## Step 5: Verify Fix
|
|
|
|
Always verify optimizations produce identical results:
|
|
|
|
```typescript
|
|
const original = originalStm.all(params);
|
|
const optimized = optimizedStm.all(params);
|
|
|
|
console.log(`Original: ${original.length} rows`);
|
|
console.log(`Optimized: ${optimized.length} rows`);
|
|
|
|
// Compare results
|
|
const originalSet = new Set(original.map(r => JSON.stringify(r)));
|
|
const optimizedSet = new Set(optimized.map(r => JSON.stringify(r)));
|
|
|
|
const match = originalSet.size === optimizedSet.size &&
|
|
[...originalSet].every(x => optimizedSet.has(x));
|
|
|
|
console.log(match ? "✓ Results match" : "✗ Results differ");
|
|
```
|
|
|
|
## Step 6: Re-benchmark
|
|
|
|
After applying fixes, restart the dev server and re-run autocannon:
|
|
|
|
```bash
|
|
# Restart server to pick up changes
|
|
# (Ctrl+C and npm run dev)
|
|
|
|
# Re-benchmark
|
|
autocannon -c 10 -d 10 http://localhost:301/leaderboards
|
|
```
|
|
|
|
## Example: Full Profiling Session
|
|
|
|
```bash
|
|
# 1. Benchmark the slow route
|
|
autocannon -c 10 -d 10 http://localhost:301/leaderboards
|
|
|
|
# 2. Create and run profiling script
|
|
cat > profile.ts << 'EOF'
|
|
import Database from "better-sqlite3";
|
|
const db = new Database("db-prod.sqlite3");
|
|
|
|
const start = performance.now();
|
|
const result = db.prepare(`YOUR_QUERY_HERE`).all({ /* params */ });
|
|
console.log(`Time: ${(performance.now() - start).toFixed(2)}ms`);
|
|
console.log(`Rows: ${result.length}`);
|
|
|
|
db.close();
|
|
EOF
|
|
|
|
npx tsx profile.ts
|
|
|
|
# 3. Clean up
|
|
rm profile.ts
|
|
```
|
|
|
|
## Route File Locations
|
|
|
|
| Route | Code Location |
|
|
|-------|---------------|
|
|
| `/leaderboards` | `app/features/leaderboards/` |
|
|
| `/q/*` | `app/features/sendouq/` |
|
|
| `/to/*` | `app/features/tournament/` |
|
|
| `/u/*` | `app/features/user-page/` |
|
|
| `/builds/*` | `app/features/builds/` |
|