Skip to content

Commit 57d61c5

Browse files
authored
feat(db): Database connection multiplexing for serverless environments (#600) (#651)
* feat(db): connection multiplexing for serverless environments (#600) Route serverless DB access through a transaction-pooling proxy (RDS Proxy or PgBouncer) so a small set of backend connections is multiplexed across many concurrent function invocations, preventing connection exhaustion. - backend/shared/db/serverlessPool.ts: transaction-pooling adapter with IAM/SCRAM-256 auth, credential refresh, withClient/withTransaction helpers, and abandoned-connection leak detection (>30s force-close). - backend/serverless/dbConfig.ts + withDatabase.ts: env-driven pool config (RDS IAM token provider) and a Lambda wrapper that releases the client in a finally block after every invocation. - backend/monitoring/connectionPoolMetrics.ts: Prometheus pool/leak metrics and structured leak alerting. - infra/terraform/{rds_proxy,pgbouncer}.tf: proxy provisioning (max ~50 pooled connections serving 500+ functions, transaction pooling). - docker-compose.yml + .env.example: local PgBouncer + Postgres for parity; all credentials read from a gitignored .env (no hardcoded secrets). Closes #600 * fix(deps): repair malformed package.json blocking install The dependencies block had a duplicate "zustand" entry and missing commas, producing invalid JSON that breaks `npm install` in CI. Keep a single zustand ^5.0.0 and the redis ^4.6.7 entry.
1 parent 7d5d82e commit 57d61c5

9 files changed

Lines changed: 772 additions & 4 deletions

File tree

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,13 @@ COMPOSE_PORT_SOROBAN=8000
2424
COMPOSE_PORT_BACKEND=3000
2525
COMPOSE_PORT_ML=8001
2626
COMPOSE_PORT_EXPO=8081
27+
28+
# Issue #600: serverless DB connection multiplexing via PgBouncer.
29+
# Point the app at PgBouncer (:6432), not Postgres directly.
30+
DB_PROXY_HOST=localhost
31+
DB_PROXY_PORT=6432
32+
DB_PROXY_AUTH_MODE=scram-256
33+
DB_PROXY_TXN_POOLING=true
34+
DB_PROXY_PREPARED_STATEMENTS=true
35+
DB_PROXY_MAX_CONN=50
36+
DB_LEAK_THRESHOLD_MS=30000
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* Connection-pool Prometheus metrics and leak detection.
3+
*
4+
* Issue #600: surface multiplexed-pool health (active/idle/waiting, checked-out
5+
* clients, and leaked-connection counts) so abandoned connections are visible
6+
* and alertable. Mirrors the lightweight scrape style of viewFreshnessMetric.
7+
*/
8+
9+
import type {
10+
ServerlessConnectionPool,
11+
CheckoutRecord,
12+
} from '../shared/db/serverlessPool';
13+
14+
/** Running totals that persist across scrapes for counter-type metrics. */
15+
interface LeakCounters {
16+
leakedTotal: number;
17+
}
18+
19+
/**
20+
* Render the pool stats as Prometheus exposition text. Counters
21+
* (`*_total`) accumulate; gauges reflect the instantaneous pool state.
22+
*/
23+
export function renderPoolMetrics(pool: ServerlessConnectionPool): string {
24+
const s = pool.stats();
25+
const lines = [
26+
'# HELP subtrackr_db_pool_connections Pooled connections to the DB proxy by state.',
27+
'# TYPE subtrackr_db_pool_connections gauge',
28+
`subtrackr_db_pool_connections{state="total"} ${s.total}`,
29+
`subtrackr_db_pool_connections{state="idle"} ${s.idle}`,
30+
`subtrackr_db_pool_connections{state="waiting"} ${s.waiting}`,
31+
`subtrackr_db_pool_connections{state="checked_out"} ${s.checkedOut}`,
32+
'# HELP subtrackr_db_pool_leaked_total Connections force-closed after exceeding the leak threshold.',
33+
'# TYPE subtrackr_db_pool_leaked_total counter',
34+
`subtrackr_db_pool_leaked_total ${s.leakedTotal}`,
35+
];
36+
return lines.join('\n') + '\n';
37+
}
38+
39+
/**
40+
* Build an HTTP `/metrics` handler for the serverless pool. Generic request /
41+
* response shape so it mounts in any Node.js HTTP server.
42+
*/
43+
export function createPoolMetricsHandler(pool: ServerlessConnectionPool) {
44+
return function handleMetrics(
45+
_req: unknown,
46+
res: { setHeader(name: string, value: string): void; end(body: string): void },
47+
): void {
48+
res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
49+
res.end(renderPoolMetrics(pool));
50+
};
51+
}
52+
53+
/**
54+
* Attach structured leak logging/alerting to a pool. Each force-closed
55+
* abandoned connection is logged with its age and origin, and an optional
56+
* `onLeak` sink (e.g. CloudWatch metric, PagerDuty) is invoked.
57+
*/
58+
export function installLeakDetection(
59+
pool: ServerlessConnectionPool,
60+
onLeak?: (info: { origin: string; ageMs: number }) => void,
61+
): LeakCounters {
62+
const counters: LeakCounters = { leakedTotal: 0 };
63+
pool.setLeakHandler((record: CheckoutRecord, ageMs: number) => {
64+
counters.leakedTotal += 1;
65+
console.error(
66+
JSON.stringify({
67+
level: 'error',
68+
event: 'db_connection_leak',
69+
origin: record.origin,
70+
ageMs,
71+
message: 'Abandoned database connection force-closed',
72+
}),
73+
);
74+
onLeak?.({ origin: record.origin, ageMs });
75+
});
76+
return counters;
77+
}

backend/serverless/dbConfig.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Serverless database configuration helpers.
3+
*
4+
* Issue #600: wires the serverless connection pool to the right proxy endpoint
5+
* and authentication mode for each environment.
6+
*
7+
* - Production (AWS): RDS Proxy with IAM authentication. The "password" is a
8+
* short-lived signed token regenerated on every connect.
9+
* - Self-hosted / staging: PgBouncer with SCRAM-SHA-256.
10+
* - Local dev: PgBouncer (docker-compose) with a static password.
11+
*/
12+
13+
import {
14+
getServerlessPool,
15+
type ServerlessConnectionPool,
16+
type ServerlessPoolConfig,
17+
type ProxyAuthMode,
18+
} from '../shared/db/serverlessPool';
19+
20+
/**
21+
* Build an RDS IAM auth-token provider. The token is signed with the AWS SDK's
22+
* RDS Signer and is valid for ~15 minutes, so we regenerate it on each connect.
23+
*
24+
* `@aws-sdk/rds-signer` is imported lazily so non-AWS deployments never need it.
25+
*/
26+
export function createRdsIamCredentialProvider(opts: {
27+
hostname: string;
28+
port: number;
29+
username: string;
30+
region?: string;
31+
}): () => Promise<string> {
32+
return async () => {
33+
const { Signer } = (await import('@aws-sdk/rds-signer')) as {
34+
Signer: new (cfg: {
35+
hostname: string;
36+
port: number;
37+
username: string;
38+
region?: string;
39+
}) => { getAuthToken(): Promise<string> };
40+
};
41+
const signer = new Signer({
42+
hostname: opts.hostname,
43+
port: opts.port,
44+
username: opts.username,
45+
region: opts.region ?? process.env['AWS_REGION'],
46+
});
47+
return signer.getAuthToken();
48+
};
49+
}
50+
51+
/**
52+
* Resolve the serverless pool configuration from the environment. Centralised
53+
* so every Lambda handler gets identical, correct pooling behaviour.
54+
*/
55+
export function resolveServerlessPoolConfig(): ServerlessPoolConfig {
56+
const authMode = (process.env['DB_PROXY_AUTH_MODE'] as ProxyAuthMode) || 'scram-256';
57+
const host = process.env['DB_PROXY_HOST'] ?? process.env['DB_HOST'] ?? 'localhost';
58+
const port = Number(process.env['DB_PROXY_PORT'] ?? 6432);
59+
const user = process.env['DB_USER'] ?? 'subtrackr_app';
60+
61+
const base: ServerlessPoolConfig = {
62+
authMode,
63+
host,
64+
port,
65+
user,
66+
database: process.env['DB_NAME'] ?? 'subtrackr',
67+
transactionPooling: process.env['DB_PROXY_TXN_POOLING'] !== 'false',
68+
preparedStatements: process.env['DB_PROXY_PREPARED_STATEMENTS'] === 'true',
69+
maxPooledConnections: Number(process.env['DB_PROXY_MAX_CONN'] ?? 50),
70+
leakDetectionThresholdMs: Number(process.env['DB_LEAK_THRESHOLD_MS'] ?? 30_000),
71+
ssl: process.env['DB_SSL'] === 'true' ? { rejectUnauthorized: true } : undefined,
72+
};
73+
74+
if (authMode === 'iam') {
75+
base.credentialProvider = createRdsIamCredentialProvider({
76+
hostname: host,
77+
port,
78+
username: user,
79+
region: process.env['AWS_REGION'],
80+
});
81+
}
82+
83+
return base;
84+
}
85+
86+
/** Get the shared serverless pool configured from the environment. */
87+
export function getConfiguredServerlessPool(): ServerlessConnectionPool {
88+
return getServerlessPool(resolveServerlessPoolConfig());
89+
}

backend/serverless/withDatabase.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* Lambda handler adaptation for pooled database access.
3+
*
4+
* Issue #600 acceptance criteria: "db.release() called after each invocation
5+
* via finally block." This wrapper makes that guarantee structural — handlers
6+
* receive a per-invocation client (or transaction) and the release happens in
7+
* a finally regardless of success, throw, or timeout.
8+
*
9+
* Usage:
10+
*
11+
* export const handler = withDatabase(async (event, ctx, db) => {
12+
* const { rows } = await db.query('SELECT 1');
13+
* return { statusCode: 200, body: JSON.stringify(rows) };
14+
* });
15+
*/
16+
17+
import type { PoolClient } from '../shared/db/serverlessPool';
18+
import { getConfiguredServerlessPool } from './dbConfig';
19+
20+
/** Minimal generic Lambda handler signature (provider-agnostic). */
21+
export type LambdaHandler<Event = unknown, Context = unknown, Result = unknown> = (
22+
event: Event,
23+
context: Context,
24+
) => Promise<Result>;
25+
26+
export type DatabaseHandler<Event = unknown, Context = unknown, Result = unknown> = (
27+
event: Event,
28+
context: Context,
29+
client: PoolClient,
30+
) => Promise<Result>;
31+
32+
export interface WithDatabaseOptions {
33+
/** Wrap the handler body in a single transaction. Default: false. */
34+
transaction?: boolean;
35+
/** Diagnostic label used in leak-detection logs. */
36+
origin?: string;
37+
}
38+
39+
/**
40+
* Wrap a Lambda handler so it runs with a pooled client that is always
41+
* released after the invocation. The underlying pool is a warm-reused
42+
* singleton, so the proxy connection is multiplexed across invocations.
43+
*/
44+
export function withDatabase<Event = unknown, Context = unknown, Result = unknown>(
45+
handler: DatabaseHandler<Event, Context, Result>,
46+
options: WithDatabaseOptions = {},
47+
): LambdaHandler<Event, Context, Result> {
48+
const origin = options.origin ?? handler.name ?? 'lambda';
49+
50+
return async (event, context) => {
51+
const pool = getConfiguredServerlessPool();
52+
const run = (client: PoolClient) => handler(event, context, client);
53+
// withClient / withTransaction both release in a finally block.
54+
return options.transaction
55+
? pool.withTransaction(run, origin)
56+
: pool.withClient(run, origin);
57+
};
58+
}

0 commit comments

Comments
 (0)