Introduces PgBouncer as a connection pooler between the API service and PostgreSQL in docker-compose.prod.yml, reducing connection overhead and improving concurrency under production load. - Add PgBouncer service (edoburu/pgbouncer:1.23.1-p2) with transaction pool mode, max_client_conn=200, default_pool_size=20 - Route API DATABASE_URL through PgBouncer (port 6432), keep direct connection (DATABASE_URL_DIRECT) for Prisma migrations/introspection - Create infra/pgbouncer/ config: pgbouncer.ini, userlist template, and entrypoint script with runtime env-var substitution - Update prisma.config.ts to prefer DATABASE_URL_DIRECT for migrations - Add K6 load test (e2e/load/pgbouncer-pool-test.js) with ramp-up to 200 VUs, pool exhaustion detection, and p95 < 2s threshold - Add PgBouncer env vars to .env.example Co-Authored-By: Paperclip <noreply@paperclip.ing>
163 lines
5.7 KiB
JavaScript
163 lines
5.7 KiB
JavaScript
// =============================================================================
|
|
// 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.');
|
|
}
|