Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ DB_USER=postgres
# Required: set a strong password
DB_PASSWORD=

# ── Connection Pool Tuning (issue #1797) ──────────────────────────────────────
# All values have sane defaults; override only when needed.
DB_POOL_MAX=20 # Max connections in the primary pool
DB_POOL_MIN=0 # Min connections to keep open
DB_IDLE_TIMEOUT_MS=10000 # Idle connection timeout (ms)
DB_CONNECTION_TIMEOUT_MS=30000 # Connection acquisition timeout (ms)
DB_STATEMENT_TIMEOUT_MS=30000 # Per-statement timeout (ms)
DB_POOL_MAX_USES=0 # Max client reuses before recycle (0 = unlimited)
DB_QUERY_WAIT_WARNING_MS=5000 # Warn when a query waits longer than this (ms)

# Pool metrics collection
DB_POOL_METRICS_ENABLED=true # Set to "false" to disable
DB_POOL_METRICS_INTERVAL_MS=10000 # Sampling interval (ms)
DB_POOL_METRICS_ALERT_THRESHOLD=0.8 # Alert when utilization exceeds this (0–1)

# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
Expand Down
50 changes: 50 additions & 0 deletions backend/config/__tests__/database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,53 @@ describe('database config', () => {
]);
});
});

// ── Pool tuning options (issue #1797) ───────────────────────────────────────

describe('pool tuning configuration (issue #1797)', () => {
it('loads default pool tuning when env vars are unset', () => {
const config = loadDatabaseConfig({});
expect(config.poolTuning.minConnections).toBe(0);
expect(config.poolTuning.maxClientUses).toBe(0);
expect(config.poolTuning.queryWaitWarningMs).toBe(5_000);
expect(config.poolTuning.metricsEnabled).toBe(true);
expect(config.poolTuning.metricsIntervalMs).toBe(10_000);
expect(config.poolTuning.metricsAlertThreshold).toBe(0.8);
});

it('reads custom pool tuning from env', () => {
const config = loadDatabaseConfig({
DB_POOL_MIN: '5',
DB_POOL_MAX_USES: '100',
DB_QUERY_WAIT_WARNING_MS: '2000',
DB_POOL_METRICS_ENABLED: 'false',
DB_POOL_METRICS_INTERVAL_MS: '5000',
DB_POOL_METRICS_ALERT_THRESHOLD: '0.9',
});
expect(config.poolTuning.minConnections).toBe(5);
expect(config.poolTuning.maxClientUses).toBe(100);
expect(config.poolTuning.queryWaitWarningMs).toBe(2_000);
expect(config.poolTuning.metricsEnabled).toBe(false);
expect(config.poolTuning.metricsIntervalMs).toBe(5_000);
expect(config.poolTuning.metricsAlertThreshold).toBe(0.9);
});

it('falls back for invalid pool tuning values', () => {
const config = loadDatabaseConfig({
DB_POOL_MIN: '-1',
DB_POOL_MAX_USES: 'not-a-number',
DB_POOL_METRICS_ALERT_THRESHOLD: '2.0',
DB_POOL_METRICS_INTERVAL_MS: '-5',
});
expect(config.poolTuning.minConnections).toBe(0);
expect(config.poolTuning.maxClientUses).toBe(0);
expect(config.poolTuning.metricsAlertThreshold).toBe(0.8);
expect(config.poolTuning.metricsIntervalMs).toBe(10_000);
});

it('DB_POOL_METRICS_ENABLED only disables on "false"', () => {
expect(loadDatabaseConfig({ DB_POOL_METRICS_ENABLED: 'false' }).poolTuning.metricsEnabled).toBe(false);
expect(loadDatabaseConfig({ DB_POOL_METRICS_ENABLED: 'true' }).poolTuning.metricsEnabled).toBe(true);
expect(loadDatabaseConfig({ DB_POOL_METRICS_ENABLED: undefined }).poolTuning.metricsEnabled).toBe(true);
});
});
59 changes: 58 additions & 1 deletion backend/config/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export interface DatabaseConfig {
staleReadDefaultSeconds: number;
/** How often to poll replication lag (ms). Default: 5000 */
lagPollIntervalMs: number;
/** Extended pool tuning options (issue #1797) */
poolTuning: PoolTuningOptions;
}

export const DEFAULT_DATABASE_CONFIG: Readonly<{
Expand Down Expand Up @@ -95,6 +97,60 @@ function poolDefaults(env: NodeJS.ProcessEnv): Pick<
};
}

/**
* Extended pool tuning options loaded from environment.
* All have sensible defaults so the pool works out-of-the-box.
*/
export interface PoolTuningOptions {
/** Minimum connections to keep open. Default: 0 (pg-pool default) */
minConnections: number;
/** Max times a client is reused before being recycled. Default: 0 (unlimited) */
maxClientUses: number;
/** Log a warning when a query waits longer than this (ms). Default: 5000 */
queryWaitWarningMs: number;
/** Whether to enable pool metrics collection. Default: true */
metricsEnabled: boolean;
/** Pool metrics sampling interval in ms. Default: 10000 */
metricsIntervalMs: number;
/** Alert threshold for pool utilization (0–1). Default: 0.8 */
metricsAlertThreshold: number;
}

export const DEFAULT_POOL_TUNING: Readonly<PoolTuningOptions> = {
minConnections: 0,
maxClientUses: 0,
queryWaitWarningMs: 5_000,
metricsEnabled: true,
metricsIntervalMs: 10_000,
metricsAlertThreshold: 0.8,
};

function loadPoolTuning(env: NodeJS.ProcessEnv): PoolTuningOptions {
return {
minConnections: parsePositiveInt(env.DB_POOL_MIN, DEFAULT_POOL_TUNING.minConnections),
maxClientUses: parsePositiveInt(env.DB_POOL_MAX_USES, DEFAULT_POOL_TUNING.maxClientUses),
queryWaitWarningMs: parsePositiveInt(
env.DB_QUERY_WAIT_WARNING_MS,
DEFAULT_POOL_TUNING.queryWaitWarningMs,
),
metricsEnabled: env.DB_POOL_METRICS_ENABLED !== 'false',
metricsIntervalMs: parsePositiveInt(
env.DB_POOL_METRICS_INTERVAL_MS,
DEFAULT_POOL_TUNING.metricsIntervalMs,
),
metricsAlertThreshold: parsePositiveFloat(
env.DB_POOL_METRICS_ALERT_THRESHOLD,
DEFAULT_POOL_TUNING.metricsAlertThreshold,
),
};
}

function parsePositiveFloat(value: string | undefined, fallback: number): number {
if (value === undefined || value === '') return fallback;
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : fallback;
}

function primaryFromParsed(
parsed: ParsedConnectionString,
env: NodeJS.ProcessEnv,
Expand Down Expand Up @@ -160,13 +216,14 @@ export function loadDatabaseConfig(env: NodeJS.ProcessEnv = process.env): Databa
DEFAULT_DATABASE_CONFIG.replicationLagFailoverMs,
),
staleReadDefaultSeconds: parsePositiveInt(
env.DB_STALE_READ_DEFAULT_SECONDS,
env.DB_STALE_READ_SECONDS,
DEFAULT_DATABASE_CONFIG.staleReadDefaultSeconds,
),
lagPollIntervalMs: parsePositiveInt(
env.DB_LAG_POLL_INTERVAL_MS,
DEFAULT_DATABASE_CONFIG.lagPollIntervalMs,
),
poolTuning: loadPoolTuning(env),
};
}

Expand Down
188 changes: 188 additions & 0 deletions backend/shared/db/__tests__/poolMetrics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { PoolMetricsCollector, type PoolMetricsSnapshot } from '../poolMetrics';
import type { Pool } from '../connectionPool';

/** Mock pool that simulates pg-pool counters. */
function createMockPool(overrides: Partial<Pool & { max: number }> = {}): Pool & {
max: number;
setCounts: (total: number, idle: number, waiting: number) => void;
} {
let totalCount = 0;
let idleCount = 0;
let waitingCount = 0;
const max = overrides.max ?? 20;

const pool: Pool & { max: number; setCounts: (t: number, i: number, w: number) => void } = {
max,
get totalCount() { return totalCount; },
get idleCount() { return idleCount; },
get waitingCount() { return waitingCount; },
query: jest.fn(),
connect: jest.fn(),
end: jest.fn(),
on: jest.fn(),
setCounts: (t: number, i: number, w: number) => {
totalCount = t;
idleCount = i;
waitingCount = w;
},
};
return pool;
}

describe('PoolMetricsCollector', () => {
it('takes a snapshot with correct derived fields', () => {
const pool = createMockPool({ max: 20 });
pool.setCounts(15, 8, 0);

const collector = new PoolMetricsCollector(pool);
const snapshot = collector.sample();

expect(snapshot.totalCount).toBe(15);
expect(snapshot.idleCount).toBe(8);
expect(snapshot.waitingCount).toBe(0);
expect(snapshot.activeCount).toBe(7);
expect(snapshot.maxConnections).toBe(20);
expect(snapshot.utilization).toBeCloseTo(0.35, 2);
expect(snapshot.saturated).toBe(false);
});

it('detects saturation when waitingCount > 0', () => {
const pool = createMockPool({ max: 10 });
pool.setCounts(10, 0, 3);

const collector = new PoolMetricsCollector(pool);
const snapshot = collector.sample();

expect(snapshot.saturated).toBe(true);
expect(snapshot.activeCount).toBe(10);
expect(snapshot.utilization).toBe(1);
});

it('calls onSnapshot for each sample', () => {
const pool = createMockPool({ max: 20 });
pool.setCounts(5, 3, 0);

const snapshots: PoolMetricsSnapshot[] = [];
const collector = new PoolMetricsCollector(pool, {
onSnapshot: (s) => snapshots.push(s),
});

collector.sample();
pool.setCounts(8, 4, 0);
collector.sample();

expect(snapshots).toHaveLength(2);
expect(snapshots[0].activeCount).toBe(2);
expect(snapshots[1].activeCount).toBe(4);
});

it('fires onAlert when utilization crosses the threshold (edge-triggered)', () => {
const pool = createMockPool({ max: 10 });
pool.setCounts(7, 3, 0); // 40% utilization

const alerts: PoolMetricsSnapshot[] = [];
const collector = new PoolMetricsCollector(pool, {
alertThreshold: 0.8,
onAlert: (s) => alerts.push(s),
});

collector.sample(); // 40% — no alert
expect(alerts).toHaveLength(0);

pool.setCounts(9, 1, 0); // 80% — crosses threshold
collector.sample();
expect(alerts).toHaveLength(1);
expect(alerts[0].utilization).toBeGreaterThanOrEqual(0.8);

pool.setCounts(10, 0, 0); // 100% — still alerting, no new alert
collector.sample();
expect(alerts).toHaveLength(1); // edge-triggered

pool.setCounts(5, 5, 0); // 0% — clears alerting
collector.sample();
expect(alerts).toHaveLength(1);

pool.setCounts(9, 1, 0); // 80% — re-crosses threshold
collector.sample();
expect(alerts).toHaveLength(2); // new alert after recovery
});

it('getHealthSummary reports healthy under normal load', () => {
const pool = createMockPool({ max: 20 });
pool.setCounts(10, 5, 0);

const collector = new PoolMetricsCollector(pool, { alertThreshold: 0.8 });
collector.sample();

const health = collector.getHealthSummary();
expect(health.healthy).toBe(true);
expect(health.saturated).toBe(false);
expect(health.snapshot).not.toBeNull();
});

it('getHealthSummary reports unhealthy when saturated', () => {
const pool = createMockPool({ max: 10 });
pool.setCounts(10, 0, 5);

const collector = new PoolMetricsCollector(pool);
collector.sample();

const health = collector.getHealthSummary();
expect(health.healthy).toBe(false);
expect(health.saturated).toBe(true);
});

it('getHealthSummary returns healthy before any sample is taken', () => {
const pool = createMockPool();
const collector = new PoolMetricsCollector(pool);
const health = collector.getHealthSummary();
expect(health.healthy).toBe(true);
expect(health.snapshot).toBeNull();
});

it('start and stop control the sampling interval', () => {
const pool = createMockPool({ max: 20 });
pool.setCounts(5, 3, 0);

const collector = new PoolMetricsCollector(pool, { intervalMs: 100 });
expect(collector.isRunning()).toBe(false);

collector.start();
expect(collector.isRunning()).toBe(true);

collector.stop();
expect(collector.isRunning()).toBe(false);
});

it('start is idempotent', () => {
const pool = createMockPool();
const collector = new PoolMetricsCollector(pool, { intervalMs: 100 });
collector.start();
collector.start();
expect(collector.isRunning()).toBe(true);
collector.stop();
});

it('getLastSnapshot returns the most recent sample', () => {
const pool = createMockPool({ max: 20 });
pool.setCounts(5, 3, 0);

const collector = new PoolMetricsCollector(pool);
expect(collector.getLastSnapshot()).toBeNull();

collector.sample();
expect(collector.getLastSnapshot()).not.toBeNull();
expect(collector.getLastSnapshot()?.activeCount).toBe(2);
});

it('handles zero maxConnections gracefully', () => {
const pool = createMockPool({ max: 0 });
pool.setCounts(0, 0, 0);

const collector = new PoolMetricsCollector(pool);
const snapshot = collector.sample();

expect(snapshot.utilization).toBe(0);
expect(snapshot.maxConnections).toBe(0);
});
});
4 changes: 4 additions & 0 deletions backend/shared/db/connectionPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export interface Pool {
totalCount: number;
idleCount: number;
waitingCount: number;
/** Configured max connections (pg-pool exposes this) */
max?: number;
}

function defaultPoolConfig(): Required<PoolConfig> {
Expand Down Expand Up @@ -124,3 +126,5 @@ export async function closePool(): Promise<void> {
}

export type { ReadWritePool };
export { PoolMetricsCollector } from './poolMetrics';
export type { PoolMetricsSnapshot, PoolMetricsOptions, ReplicaLagState } from './poolMetrics';
Loading
Loading