1+ import { Logger } from "@aws-lambda-powertools/logger" ;
12import { getSecret } from "@aws-lambda-powertools/parameters/secrets" ;
23import { PrismaPg } from "@prisma/adapter-pg" ;
34import { PrismaClient } from "@prisma/client" ;
5+ import { Pool } from "pg" ;
6+ import { DatabaseCircuitBreaker } from "./database-circuit-breaker.js" ;
47
58interface DbSecret {
69 username : string ;
@@ -10,7 +13,73 @@ interface DbSecret {
1013 dbname : string ;
1114}
1215
16+ const logger = new Logger ( { serviceName : "lambda-prisma" } ) ;
17+
18+ /**
19+ * Operational knobs — env-configurable with safe defaults (threshold-secrecy
20+ * rule: never compile operational limits into the public tarball).
21+ *
22+ * Why the pool MUST be capped here: each warm Lambda execution environment holds
23+ * its OWN pg pool, and every concurrent environment shares the RDS
24+ * `max_connections` budget (~107 usable on a t4g.micro). Under a signup burst —
25+ * or a `pre-token` cache-miss storm — an unbounded pool lets a single function
26+ * exhaust the instance, and Postgres then rejects connections from EVERY client
27+ * (a global outage, not just failed signups). `?connection_limit=1` in the
28+ * connection URL is a **no-op** under `@prisma/adapter-pg` (it is a Prisma-engine
29+ * parameter the `pg` driver ignores), so the cap is set on the pool object — the
30+ * only place it takes effect. See
31+ * trellis-internal `analysis/db-connection-management/signup-burst-connection-exhaustion.md`.
32+ */
33+ const DEFAULT_POOL_MAX = 1 ;
34+ const DEFAULT_CONNECT_TIMEOUT_MS = 2000 ;
35+ const DEFAULT_IDLE_TIMEOUT_MS = 10_000 ;
36+ const DEFAULT_BREAKER_THRESHOLD = 5 ;
37+ const DEFAULT_BREAKER_COOLDOWN_MS = 30_000 ;
38+
1339let prisma : PrismaClient | null = null ;
40+ let pool : Pool | null = null ;
41+
42+ /**
43+ * Per-execution-environment circuit breaker for the Lambda DB path.
44+ *
45+ * The request path (`DatabaseConnectionManager`) has a breaker; Lambda handlers
46+ * did not — so under DB saturation they would keep retrying into a saturated
47+ * instance and amplify the incident. Opening the breaker makes a saturated
48+ * environment fail fast (no connect-timeout wait, no slot held) until a cooldown
49+ * probe succeeds. Module scope = one warm Lambda environment, the right blast
50+ * radius. Wrap every RDS access in a handler with `withLambdaDbBreaker`.
51+ */
52+ export const lambdaDbBreaker = new DatabaseCircuitBreaker ( {
53+ failureThreshold : Number (
54+ process . env . LAMBDA_DATABASE_BREAKER_THRESHOLD ?? DEFAULT_BREAKER_THRESHOLD ,
55+ ) ,
56+ cooldownMs : Number (
57+ process . env . LAMBDA_DATABASE_BREAKER_COOLDOWN_MS ?? DEFAULT_BREAKER_COOLDOWN_MS ,
58+ ) ,
59+ } ) ;
60+
61+ /**
62+ * Run a unit of DB work under the Lambda circuit breaker. Use around every RDS
63+ * access in a Lambda handler so connection-exhaustion failures trip the breaker
64+ * instead of being retried into a saturated instance. When the breaker is OPEN
65+ * the call throws immediately (message begins "Circuit breaker is OPEN").
66+ */
67+ export async function withLambdaDbBreaker < T > (
68+ fn : ( ) => Promise < T > ,
69+ operation ?: string ,
70+ ) : Promise < T > {
71+ try {
72+ return await lambdaDbBreaker . execute ( fn , { operation } ) ;
73+ } catch ( err ) {
74+ if (
75+ err instanceof Error &&
76+ err . message . startsWith ( "Circuit breaker is OPEN" )
77+ ) {
78+ logger . warn ( "lambda_db.breaker_open" , { operation } ) ;
79+ }
80+ throw err ;
81+ }
82+ }
1483
1584/**
1685 * Build (and cache) a PrismaClient for standalone Lambda handlers.
@@ -21,20 +90,39 @@ let prisma: PrismaClient | null = null;
2190 * `DatabaseConnectionManager` (`ssl: { rejectUnauthorized: false }`); Lambda
2291 * handlers must do the same. Prisma 7 supplies the connection through a pg
2392 * driver adapter (the old `datasources` constructor option is gone), so the
24- * `ssl` option goes on the adapter's pool config.
93+ * `ssl` option and the size cap go on the pool config.
94+ *
95+ * The pool is passed to `PrismaPg` as an explicit `pg.Pool` (NOT a
96+ * connection-string config) because the pool is the only place `max` takes
97+ * effect — see the knobs comment above.
2598 *
26- * Cached at module scope so warm invocations reuse the client.
99+ * Cached at module scope so warm invocations reuse the client (and its single
100+ * connection).
27101 */
28102export async function getLambdaPrisma ( ) : Promise < PrismaClient > {
29103 if ( prisma ) return prisma ;
30104 const { username, password, host, port, dbname } = ( await getSecret (
31105 process . env . DB_SECRET_ARN ! ,
32106 { transform : "json" } ,
33107 ) ) as unknown as DbSecret ;
34- const adapter = new PrismaPg ( {
35- connectionString : `postgresql://${ username } :${ encodeURIComponent ( password ) } @${ host } :${ port } /${ dbname } ?connection_limit=1` ,
108+
109+ // When an RDS Proxy is provisioned, the infra injects its endpoint here so the
110+ // Lambda connects through the proxy (which multiplexes and caps the
111+ // connections the DB ever sees); fall back to the direct instance endpoint
112+ // when unset, so the default deployment is unchanged.
113+ const dbHost = process . env . LAMBDA_DATABASE_PROXY_HOST || host ;
114+
115+ pool = new Pool ( {
116+ connectionString : `postgresql://${ username } :${ encodeURIComponent ( password ) } @${ dbHost } :${ port } /${ dbname } ` ,
36117 ssl : { rejectUnauthorized : false } ,
118+ max : Number ( process . env . LAMBDA_DATABASE_POOL_MAX ?? DEFAULT_POOL_MAX ) ,
119+ connectionTimeoutMillis : Number (
120+ process . env . LAMBDA_DATABASE_CONNECT_TIMEOUT_MS ?? DEFAULT_CONNECT_TIMEOUT_MS ,
121+ ) ,
122+ idleTimeoutMillis : DEFAULT_IDLE_TIMEOUT_MS ,
123+ allowExitOnIdle : false ,
37124 } ) ;
125+ const adapter = new PrismaPg ( pool ) ;
38126 prisma = new PrismaClient ( { adapter } ) ;
39127 return prisma ;
40128}
0 commit comments