// ============================================================================= // K6 Load Test — PgBouncer Connection Pooling Validation // // This script validates that PgBouncer handles concurrent database connections // correctly under production-like load. It targets the GoodGo API endpoints // that trigger database queries (health, listings search, auth). // // Usage: // k6 run e2e/load/pgbouncer-pool-test.js // k6 run --env BASE_URL=http://localhost:3001 e2e/load/pgbouncer-pool-test.js // // Requirements: // - GoodGo API running (docker-compose.prod.yml or local dev) // - PgBouncer service running and healthy // - Seeded database (pnpm db:seed) // ============================================================================= import http from 'k6/http'; import { check, group, sleep } from 'k6'; import { Rate, Trend, Counter } from 'k6/metrics'; // ── Custom Metrics ─────────────────────────────────────────────────────────── const errorRate = new Rate('errors'); const dbQueryDuration = new Trend('db_query_duration', true); const poolExhaustedErrors = new Counter('pool_exhausted_errors'); // ── Configuration ──────────────────────────────────────────────────────────── const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001'; export const options = { // Ramp-up pattern to stress-test connection pooling stages: [ // Warm-up: gradually increase to 50 concurrent users { duration: '30s', target: 50 }, // Sustained load: hold at 50 users { duration: '1m', target: 50 }, // Spike: jump to 150 users (should exceed default_pool_size=20) { duration: '30s', target: 150 }, // Peak: hold at 200 users (matches max_client_conn=200) { duration: '1m', target: 200 }, // Cool-down: ramp back to 0 { duration: '30s', target: 0 }, ], thresholds: { // 95th percentile response time under 2 seconds http_req_duration: ['p(95)<2000'], // Error rate under 5% errors: ['rate<0.05'], // No pool-exhaustion errors pool_exhausted_errors: ['count<1'], // 99% of requests succeed http_req_failed: ['rate<0.01'], }, }; // ── Helper Functions ───────────────────────────────────────────────────────── function getHeaders() { return { 'Content-Type': 'application/json', Accept: 'application/json', }; } function checkResponse(res, name) { const success = check(res, { [`${name}: status 2xx`]: (r) => r.status >= 200 && r.status < 300, [`${name}: response time < 2s`]: (r) => r.timings.duration < 2000, }); errorRate.add(!success); // Detect pool exhaustion (typically 503 or connection refused) if (res.status === 503 || res.status === 0) { poolExhaustedErrors.add(1); } if (res.timings.duration) { dbQueryDuration.add(res.timings.duration); } return success; } // ── Test Scenarios ─────────────────────────────────────────────────────────── export default function () { // Each VU runs a mix of API calls that hit the database group('Health Check (DB connectivity)', () => { const res = http.get(`${BASE_URL}/health`, { headers: getHeaders() }); checkResponse(res, 'health'); }); group('Listings Search (read-heavy, geo queries)', () => { // Search listings — triggers PostGIS queries through the pool const searchParams = { page: 1, limit: 20, }; const res = http.get( `${BASE_URL}/api/v1/listings?page=${searchParams.page}&limit=${searchParams.limit}`, { headers: getHeaders() }, ); checkResponse(res, 'listings-search'); }); group('Listings Detail (single record lookup)', () => { // Fetch a specific listing — simulates detail page views const res = http.get(`${BASE_URL}/api/v1/listings?page=1&limit=1`, { headers: getHeaders(), }); checkResponse(res, 'listings-detail'); }); group('Concurrent Burst (pool stress)', () => { // Fire multiple requests in parallel to stress the connection pool const responses = http.batch([ ['GET', `${BASE_URL}/health`, null, { headers: getHeaders() }], [ 'GET', `${BASE_URL}/api/v1/listings?page=1&limit=5`, null, { headers: getHeaders() }, ], [ 'GET', `${BASE_URL}/api/v1/listings?page=2&limit=5`, null, { headers: getHeaders() }, ], ]); responses.forEach((res, i) => { checkResponse(res, `burst-request-${i}`); }); }); // Brief pause between iterations to simulate realistic user behavior sleep(0.5 + Math.random() * 1.5); } // ── Lifecycle Hooks ────────────────────────────────────────────────────────── export function setup() { // Verify the API is reachable before starting load test const res = http.get(`${BASE_URL}/health`); if (res.status !== 200) { throw new Error( `API is not healthy at ${BASE_URL}/health — got status ${res.status}. ` + 'Ensure docker-compose.prod.yml services are running.', ); } console.log(`API healthy at ${BASE_URL}. Starting PgBouncer pool load test...`); return { baseUrl: BASE_URL }; } export function teardown(data) { console.log(`Load test complete against ${data.baseUrl}.`); console.log('Review k6 output for pool_exhausted_errors and db_query_duration metrics.'); }