Skip to content

Commit 533edfb

Browse files
authored
feat(db): add read-replica routing with lag monitoring and Terraform (#644)
* feat(db): add read-replica routing with lag monitoring and Terraform Route SELECT/WITH queries to read replicas via ReadWritePool, with automatic failback to primary on high lag or replica failure, PgBouncer-backed local dev, Prometheus replication metrics, and RDS read replica Terraform provisioning. * chore: automated code formatting fixes via CI pipeline * chore: automated code formatting fixes via CI pipeline
1 parent ff91931 commit 533edfb

41 files changed

Lines changed: 2537 additions & 943 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {
2+
DEFAULT_DATABASE_CONFIG,
3+
loadDatabaseConfig,
4+
replicaPoolConfig,
5+
} from '../database';
6+
7+
describe('database config', () => {
8+
it('loads primary defaults when env vars are unset', () => {
9+
const config = loadDatabaseConfig({});
10+
expect(config.primary.host).toBe('localhost');
11+
expect(config.primary.port).toBe(5432);
12+
expect(config.primary.database).toBe('subtrackr');
13+
expect(config.primary.user).toBe('postgres');
14+
expect(config.primary.max).toBe(20);
15+
expect(config.replicas).toEqual([]);
16+
expect(config.replicaPoolSize).toBe(DEFAULT_DATABASE_CONFIG.replicaPoolSize);
17+
expect(config.replicationLagP99AlarmMs).toBe(1_000);
18+
expect(config.replicationLagFailoverMs).toBe(5_000);
19+
expect(config.staleReadDefaultSeconds).toBe(30);
20+
});
21+
22+
it('parses comma-separated read replica endpoints', () => {
23+
const config = loadDatabaseConfig({
24+
DB_READ_REPLICAS: 'replica-a.internal:6432,replica-b.internal:6433',
25+
});
26+
expect(config.replicas).toEqual([
27+
{ name: 'replica-1', host: 'replica-a.internal', port: 6432 },
28+
{ name: 'replica-2', host: 'replica-b.internal', port: 6433 },
29+
]);
30+
});
31+
32+
it('parses replica host without explicit port', () => {
33+
const config = loadDatabaseConfig({
34+
DB_READ_REPLICAS: 'replica-only.internal',
35+
});
36+
expect(config.replicas).toEqual([
37+
{ name: 'replica-1', host: 'replica-only.internal', port: 5432 },
38+
]);
39+
});
40+
41+
it('reads custom lag and pool thresholds', () => {
42+
const config = loadDatabaseConfig({
43+
DB_REPLICA_POOL_SIZE: '50',
44+
DB_REPLICATION_LAG_P99_ALARM_MS: '800',
45+
DB_REPLICATION_LAG_FAILOVER_MS: '4000',
46+
DB_STALE_READ_DEFAULT_SECONDS: '60',
47+
DB_LAG_POLL_INTERVAL_MS: '10000',
48+
});
49+
expect(config.replicaPoolSize).toBe(50);
50+
expect(config.replicationLagP99AlarmMs).toBe(800);
51+
expect(config.replicationLagFailoverMs).toBe(4_000);
52+
expect(config.staleReadDefaultSeconds).toBe(60);
53+
expect(config.lagPollIntervalMs).toBe(10_000);
54+
});
55+
56+
it('falls back for invalid numeric env values', () => {
57+
const config = loadDatabaseConfig({
58+
DB_PORT: 'not-a-number',
59+
DB_REPLICA_POOL_SIZE: '-1',
60+
});
61+
expect(config.primary.port).toBe(5432);
62+
expect(config.replicaPoolSize).toBe(DEFAULT_DATABASE_CONFIG.replicaPoolSize);
63+
});
64+
65+
it('builds replica pool config with PgBouncer pool size', () => {
66+
const base = loadDatabaseConfig({}).primary;
67+
const replica = { name: 'replica-1', host: 'pgbouncer-1', port: 6433 };
68+
const poolConfig = replicaPoolConfig(replica, base, 25);
69+
expect(poolConfig.host).toBe('pgbouncer-1');
70+
expect(poolConfig.port).toBe(6433);
71+
expect(poolConfig.max).toBe(25);
72+
expect(poolConfig.database).toBe(base.database);
73+
});
74+
});

backend/config/database.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* PostgreSQL connection configuration with read-replica endpoints.
3+
*
4+
* Environment variables (primary):
5+
* DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD, DB_SSL
6+
*
7+
* Read replicas (optional — comma-separated host:port pairs):
8+
* DB_READ_REPLICAS – e.g. "replica-1.internal:6432,replica-2.internal:6433"
9+
* DB_REPLICA_POOL_SIZE – PgBouncer pool size per replica (default: 25)
10+
*
11+
* Replication lag thresholds (milliseconds):
12+
* DB_REPLICATION_LAG_P99_ALARM_MS – P99 alarm threshold (default: 1000)
13+
* DB_REPLICATION_LAG_FAILOVER_MS – route reads to primary above this (default: 5000)
14+
*
15+
* Stale reads:
16+
* DB_STALE_READ_DEFAULT_SECONDS – default X-Stale-Accept for analytics (default: 30)
17+
*/
18+
19+
import type { PoolConfig } from '../shared/db/connectionPool';
20+
21+
export interface ReplicaEndpoint {
22+
/** Logical name used in metrics labels (replica-1, replica-2, …). */
23+
name: string;
24+
host: string;
25+
port: number;
26+
}
27+
28+
export interface DatabaseConfig {
29+
primary: Required<PoolConfig>;
30+
replicas: ReplicaEndpoint[];
31+
/** PgBouncer pool size per replica. Default: 25 */
32+
replicaPoolSize: number;
33+
/** P99 replication lag alarm threshold in ms. Default: 1000 */
34+
replicationLagP99AlarmMs: number;
35+
/** Lag above which reads fail back to primary. Default: 5000 */
36+
replicationLagFailoverMs: number;
37+
/** Default stale-read tolerance for analytics endpoints (seconds). Default: 30 */
38+
staleReadDefaultSeconds: number;
39+
/** How often to poll replication lag (ms). Default: 5000 */
40+
lagPollIntervalMs: number;
41+
}
42+
43+
export const DEFAULT_DATABASE_CONFIG: Readonly<{
44+
replicaPoolSize: number;
45+
replicationLagP99AlarmMs: number;
46+
replicationLagFailoverMs: number;
47+
staleReadDefaultSeconds: number;
48+
lagPollIntervalMs: number;
49+
}> = {
50+
replicaPoolSize: 25,
51+
replicationLagP99AlarmMs: 1_000,
52+
replicationLagFailoverMs: 5_000,
53+
staleReadDefaultSeconds: 30,
54+
lagPollIntervalMs: 5_000,
55+
};
56+
57+
function parsePositiveInt(value: string | undefined, fallback: number): number {
58+
if (value === undefined || value === '') return fallback;
59+
const parsed = Number.parseInt(value, 10);
60+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
61+
}
62+
63+
function parseReplicaEndpoints(raw: string | undefined): ReplicaEndpoint[] {
64+
if (!raw?.trim()) return [];
65+
66+
return raw
67+
.split(',')
68+
.map((entry) => entry.trim())
69+
.filter(Boolean)
70+
.map((entry, index) => {
71+
const [host, portStr] = entry.includes(':') ? entry.split(':') : [entry, undefined];
72+
return {
73+
name: `replica-${index + 1}`,
74+
host: host.trim(),
75+
port: parsePositiveInt(portStr, 5432),
76+
};
77+
});
78+
}
79+
80+
function buildPrimaryConfig(env: NodeJS.ProcessEnv): Required<PoolConfig> {
81+
return {
82+
host: env.DB_HOST?.trim() || 'localhost',
83+
port: parsePositiveInt(env.DB_PORT, 5432),
84+
database: env.DB_NAME?.trim() || 'subtrackr',
85+
user: env.DB_USER?.trim() || 'postgres',
86+
password: env.DB_PASSWORD ?? '',
87+
max: parsePositiveInt(env.DB_POOL_MAX, 20),
88+
idleTimeoutMillis: parsePositiveInt(env.DB_IDLE_TIMEOUT_MS, 10_000),
89+
connectionTimeoutMillis: parsePositiveInt(env.DB_CONNECTION_TIMEOUT_MS, 30_000),
90+
statementTimeout: parsePositiveInt(env.DB_STATEMENT_TIMEOUT_MS, 30_000),
91+
ssl: env.DB_SSL === 'true' ? { rejectUnauthorized: true } : false,
92+
};
93+
}
94+
95+
/** Load database configuration from environment variables. */
96+
export function loadDatabaseConfig(env: NodeJS.ProcessEnv = process.env): DatabaseConfig {
97+
return {
98+
primary: buildPrimaryConfig(env),
99+
replicas: parseReplicaEndpoints(env.DB_READ_REPLICAS),
100+
replicaPoolSize: parsePositiveInt(
101+
env.DB_REPLICA_POOL_SIZE,
102+
DEFAULT_DATABASE_CONFIG.replicaPoolSize,
103+
),
104+
replicationLagP99AlarmMs: parsePositiveInt(
105+
env.DB_REPLICATION_LAG_P99_ALARM_MS,
106+
DEFAULT_DATABASE_CONFIG.replicationLagP99AlarmMs,
107+
),
108+
replicationLagFailoverMs: parsePositiveInt(
109+
env.DB_REPLICATION_LAG_FAILOVER_MS,
110+
DEFAULT_DATABASE_CONFIG.replicationLagFailoverMs,
111+
),
112+
staleReadDefaultSeconds: parsePositiveInt(
113+
env.DB_STALE_READ_DEFAULT_SECONDS,
114+
DEFAULT_DATABASE_CONFIG.staleReadDefaultSeconds,
115+
),
116+
lagPollIntervalMs: parsePositiveInt(
117+
env.DB_LAG_POLL_INTERVAL_MS,
118+
DEFAULT_DATABASE_CONFIG.lagPollIntervalMs,
119+
),
120+
};
121+
}
122+
123+
/** Build a pg PoolConfig for a read replica (via PgBouncer). */
124+
export function replicaPoolConfig(
125+
replica: ReplicaEndpoint,
126+
base: Required<PoolConfig>,
127+
poolSize: number,
128+
): Required<PoolConfig> {
129+
return {
130+
...base,
131+
host: replica.host,
132+
port: replica.port,
133+
max: poolSize,
134+
};
135+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { DatabaseConfig } from '../../config/database';
2+
import type { Pool } from '../shared/db/connectionPool';
3+
import type { ReplicaLagState, ReplicaQueryStats } from '../shared/db/readWriteRouter';
4+
import { formatReplicationPrometheus } from '../replicationLagExporter';
5+
6+
function makePoolStats(total: number, idle: number, waiting: number): Pool {
7+
return {
8+
query: jest.fn(),
9+
connect: jest.fn(),
10+
end: jest.fn(),
11+
on: jest.fn(),
12+
totalCount: total,
13+
idleCount: idle,
14+
waitingCount: waiting,
15+
} as unknown as Pool;
16+
}
17+
18+
describe('replicationLagExporter', () => {
19+
it('formats lag, pool, and query latency metrics', () => {
20+
const config: DatabaseConfig = {
21+
primary: {
22+
host: 'primary',
23+
port: 5432,
24+
database: 'subtrackr',
25+
user: 'postgres',
26+
password: '',
27+
max: 20,
28+
idleTimeoutMillis: 10_000,
29+
connectionTimeoutMillis: 30_000,
30+
statementTimeout: 30_000,
31+
ssl: false,
32+
},
33+
replicas: [{ name: 'replica-1', host: 'r1', port: 6433 }],
34+
replicaPoolSize: 25,
35+
replicationLagP99AlarmMs: 1_000,
36+
replicationLagFailoverMs: 5_000,
37+
staleReadDefaultSeconds: 30,
38+
lagPollIntervalMs: 5_000,
39+
};
40+
41+
const lagStates: ReplicaLagState[] = [
42+
{ name: 'replica-1', lagMs: 250, lagP99Ms: 400, available: true, lastCheckedAt: Date.now() },
43+
];
44+
const queryStats: ReplicaQueryStats[] = [
45+
{
46+
name: 'replica-1',
47+
queryCount: 42,
48+
totalLatencyMs: 840,
49+
lastLatencyMs: 20,
50+
errors: 1,
51+
},
52+
];
53+
54+
const replicaPools = new Map([['replica-1', makePoolStats(25, 10, 2)]]);
55+
56+
const mockPool = {
57+
getReplicaPools: () => replicaPools,
58+
};
59+
60+
const output = formatReplicationPrometheus(
61+
{ lagStates, queryStats, config },
62+
mockPool as never,
63+
);
64+
65+
expect(output).toContain('subtrackr_replication_lag_ms{replica="replica-1"} 250');
66+
expect(output).toContain('subtrackr_replication_lag_p99_ms{replica="replica-1"} 400');
67+
expect(output).toContain('subtrackr_replication_lag_failover_ms 5000');
68+
expect(output).toContain('subtrackr_replica_available{replica="replica-1"} 1');
69+
expect(output).toContain('subtrackr_replica_pool_idle{replica="replica-1"} 10');
70+
expect(output).toContain('subtrackr_replica_query_latency_ms{replica="replica-1"} 20');
71+
expect(output).toContain('subtrackr_replica_query_total{replica="replica-1"} 42');
72+
expect(output).toContain('subtrackr_replica_query_errors_total{replica="replica-1"} 1');
73+
});
74+
75+
it('handles unavailable replica with -1 lag', () => {
76+
const config = {
77+
replicationLagP99AlarmMs: 1_000,
78+
replicationLagFailoverMs: 5_000,
79+
} as DatabaseConfig;
80+
81+
const lagStates: ReplicaLagState[] = [
82+
{ name: 'replica-2', lagMs: Infinity, lagP99Ms: 0, available: false, lastCheckedAt: 0 },
83+
];
84+
85+
const output = formatReplicationPrometheus(
86+
{ lagStates, queryStats: [], config },
87+
{ getReplicaPools: () => new Map() } as never,
88+
);
89+
90+
expect(output).toContain('subtrackr_replica_available{replica="replica-2"} 0');
91+
});
92+
});

0 commit comments

Comments
 (0)