# Load Testing Guide

This guide explains how to profile and optimize slow routes in sendou.ink.

## Prerequisites

Install autocannon globally:

```bash
pnpm add -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
pnpm exec vite-node 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 pnpm 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

pnpm exec vite-node 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/` |
