Skip to content

Commit 58bf4e2

Browse files
authored
feat(webapp): per-client database pool and connect timeout overrides (#4515)
## Summary Follow-on to #4513. The database connect timeout is now honored, but a single global value has to serve three separate databases at once (control-plane, legacy run-ops, and run-ops). This adds optional per-client overrides for the Prisma pool and connect timeouts, one pair for the writer and one for the read replica of each of the three databases, each falling back to the shared `DATABASE_POOL_TIMEOUT` / `DATABASE_CONNECTION_TIMEOUT` when unset. That lets one database's clients run a fail-fast connect timeout (with a bounded pool wait) while another keeps more headroom, without a single knob forcing the same tradeoff everywhere. No behavior change until an override is set. It also tags each client's queries with its specific datasource (`control-plane` / `legacy-run-ops` / `run-ops`, writer or replica) via the `db.datasource` span attribute, so telemetry can attribute connection behavior to a specific database instead of just writer-vs-replica.
1 parent 1a16d61 commit 58bf4e2

4 files changed

Lines changed: 97 additions & 19 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Allow the database connection pool and connect timeouts to be tuned separately for each database's writer and read replica, falling back to the shared defaults when unset.

apps/webapp/app/db.server.ts

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,15 @@ async function $transactionInner<R>(
124124

125125
export { Prisma };
126126

127-
function tagDatasource<T extends PrismaClient>(datasource: "writer" | "replica", client: T): T {
127+
type DatasourceLabel =
128+
| "control-plane-writer"
129+
| "control-plane-replica"
130+
| "legacy-run-ops-writer"
131+
| "legacy-run-ops-replica"
132+
| "run-ops-writer"
133+
| "run-ops-replica";
134+
135+
function tagDatasource<T extends PrismaClient>(datasource: DatasourceLabel, client: T): T {
128136
return client.$extends({
129137
name: "datasource-tagger",
130138
query: {
@@ -142,7 +150,7 @@ function tagDatasource<T extends PrismaClient>(datasource: "writer" | "replica",
142150
// Same extension as tagDatasource but typed for RunOpsPrismaClient (different
143151
// generated package — does not extend @trigger.dev/database.PrismaClient).
144152
function tagDatasourceRunOps(
145-
datasource: "writer" | "replica",
153+
datasource: DatasourceLabel,
146154
client: RunOpsPrismaClient
147155
): RunOpsPrismaClient {
148156
return client.$extends({
@@ -168,15 +176,17 @@ function captureInfraErrorsRunOps(client: RunOpsPrismaClient): RunOpsPrismaClien
168176
}
169177

170178
export const prisma = singleton("prisma", () =>
171-
captureInfrastructureErrors(tagDatasource("writer", getClient()))
179+
captureInfrastructureErrors(tagDatasource("control-plane-writer", getClient()))
172180
);
173181

174182
export const $replica: PrismaReplicaClient = singleton("replica", () => {
175183
const replica = getReplicaClient();
176184
// Brand ONLY a real replica so the run-store routing layer keeps replica reads off the primary.
177185
// No replica configured → fall back to the writer `prisma`, which must stay UNBRANDED.
178186
return replica
179-
? markReadReplicaClient(captureInfrastructureErrors(tagDatasource("replica", replica)))
187+
? markReadReplicaClient(
188+
captureInfrastructureErrors(tagDatasource("control-plane-replica", replica))
189+
)
180190
: prisma;
181191
});
182192

@@ -296,27 +306,43 @@ const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => {
296306
controlPlane: { writer: prisma, replica: $replica },
297307
buildNewWriter: (url, clientType) =>
298308
captureInfraErrorsRunOps(
299-
tagDatasourceRunOps("writer", buildRunOpsWriterClient({ url, clientType }))
309+
tagDatasourceRunOps("run-ops-writer", buildRunOpsWriterClient({ url, clientType }))
300310
),
301311
// Brand the run-ops replica (only built for a real replica URL) so routed replica reads stay
302312
// off the primary. When no replica URL is set, selectRunOpsTopology reuses the writer here —
303313
// which this callback never touches, so the writer stays unbranded.
304314
buildNewReplica: (url, clientType) =>
305315
markReadReplicaClient(
306316
captureInfraErrorsRunOps(
307-
tagDatasourceRunOps("replica", buildRunOpsReplicaClient({ url, clientType }))
317+
tagDatasourceRunOps("run-ops-replica", buildRunOpsReplicaClient({ url, clientType }))
308318
)
309319
),
310320
// Legacy client shares the exact control-plane wrapper stack (the legacy DB carries the full
311321
// control-plane schema); markReadReplicaClient only on a real replica URL, as with the NEW replica.
312322
buildLegacyWriter: (url, clientType) =>
313323
captureInfrastructureErrors(
314-
tagDatasource("writer", buildWriterClient({ url, clientType }))
324+
tagDatasource(
325+
"legacy-run-ops-writer",
326+
buildWriterClient({
327+
url,
328+
clientType,
329+
poolTimeout: env.RUN_OPS_LEGACY_DATABASE_WRITER_POOL_TIMEOUT,
330+
connectTimeout: env.RUN_OPS_LEGACY_DATABASE_WRITER_CONNECTION_TIMEOUT,
331+
})
332+
)
315333
),
316334
buildLegacyReplica: (url, clientType) =>
317335
markReadReplicaClient(
318336
captureInfrastructureErrors(
319-
tagDatasource("replica", buildReplicaClient({ url, clientType }))
337+
tagDatasource(
338+
"legacy-run-ops-replica",
339+
buildReplicaClient({
340+
url,
341+
clientType,
342+
poolTimeout: env.RUN_OPS_LEGACY_DATABASE_READ_REPLICA_POOL_TIMEOUT,
343+
connectTimeout: env.RUN_OPS_LEGACY_DATABASE_READ_REPLICA_CONNECTION_TIMEOUT,
344+
})
345+
)
320346
)
321347
),
322348
}
@@ -383,7 +409,12 @@ function getClient() {
383409
const url = env.CONTROL_PLANE_DATABASE_URL ?? env.DATABASE_URL;
384410
invariant(typeof url === "string", "neither CONTROL_PLANE_DATABASE_URL nor DATABASE_URL is set");
385411

386-
return buildWriterClient({ url, clientType: "writer" });
412+
return buildWriterClient({
413+
url,
414+
clientType: "writer",
415+
poolTimeout: env.DATABASE_WRITER_POOL_TIMEOUT,
416+
connectTimeout: env.DATABASE_WRITER_CONNECTION_TIMEOUT,
417+
});
387418
}
388419

389420
// Generalized writer builder shared by the control-plane client and the run-ops
@@ -392,14 +423,18 @@ function getClient() {
392423
export function buildWriterClient({
393424
url,
394425
clientType,
426+
poolTimeout,
427+
connectTimeout,
395428
}: {
396429
url: string;
397430
clientType: string;
431+
poolTimeout?: number;
432+
connectTimeout?: number;
398433
}): PrismaClient {
399434
const databaseUrl = buildPrismaConnectionUrl(url, {
400435
connectionLimit: env.DATABASE_CONNECTION_LIMIT.toString(),
401-
poolTimeout: env.DATABASE_POOL_TIMEOUT.toString(),
402-
connectTimeout: env.DATABASE_CONNECTION_TIMEOUT.toString(),
436+
poolTimeout: (poolTimeout ?? env.DATABASE_POOL_TIMEOUT).toString(),
437+
connectTimeout: (connectTimeout ?? env.DATABASE_CONNECTION_TIMEOUT).toString(),
403438
applicationName: env.SERVICE_NAME,
404439
});
405440

@@ -530,7 +565,12 @@ function getReplicaClient() {
530565
return;
531566
}
532567

533-
return buildReplicaClient({ url, clientType: "reader" });
568+
return buildReplicaClient({
569+
url,
570+
clientType: "reader",
571+
poolTimeout: env.DATABASE_READ_REPLICA_POOL_TIMEOUT,
572+
connectTimeout: env.DATABASE_READ_REPLICA_CONNECTION_TIMEOUT,
573+
});
534574
}
535575

536576
// Generalized replica builder shared by the control-plane replica and the run-ops
@@ -539,14 +579,18 @@ function getReplicaClient() {
539579
export function buildReplicaClient({
540580
url,
541581
clientType,
582+
poolTimeout,
583+
connectTimeout,
542584
}: {
543585
url: string;
544586
clientType: string;
587+
poolTimeout?: number;
588+
connectTimeout?: number;
545589
}): PrismaClient {
546590
const replicaUrl = buildPrismaConnectionUrl(url, {
547591
connectionLimit: env.DATABASE_CONNECTION_LIMIT.toString(),
548-
poolTimeout: env.DATABASE_POOL_TIMEOUT.toString(),
549-
connectTimeout: env.DATABASE_CONNECTION_TIMEOUT.toString(),
592+
poolTimeout: (poolTimeout ?? env.DATABASE_POOL_TIMEOUT).toString(),
593+
connectTimeout: (connectTimeout ?? env.DATABASE_CONNECTION_TIMEOUT).toString(),
550594
applicationName: env.SERVICE_NAME,
551595
});
552596

@@ -675,8 +719,10 @@ function buildRunOpsWriterClient({
675719
}): RunOpsPrismaClient {
676720
const databaseUrl = buildPrismaConnectionUrl(url, {
677721
connectionLimit: env.DATABASE_CONNECTION_LIMIT.toString(),
678-
poolTimeout: env.DATABASE_POOL_TIMEOUT.toString(),
679-
connectTimeout: env.DATABASE_CONNECTION_TIMEOUT.toString(),
722+
poolTimeout: (env.RUN_OPS_DATABASE_WRITER_POOL_TIMEOUT ?? env.DATABASE_POOL_TIMEOUT).toString(),
723+
connectTimeout: (
724+
env.RUN_OPS_DATABASE_WRITER_CONNECTION_TIMEOUT ?? env.DATABASE_CONNECTION_TIMEOUT
725+
).toString(),
680726
applicationName: env.SERVICE_NAME,
681727
});
682728

@@ -728,8 +774,12 @@ function buildRunOpsReplicaClient({
728774
connectionLimit: (
729775
env.RUN_OPS_DATABASE_READ_REPLICA_CONNECTION_LIMIT ?? env.DATABASE_CONNECTION_LIMIT
730776
).toString(),
731-
poolTimeout: env.DATABASE_POOL_TIMEOUT.toString(),
732-
connectTimeout: env.DATABASE_CONNECTION_TIMEOUT.toString(),
777+
poolTimeout: (
778+
env.RUN_OPS_DATABASE_READ_REPLICA_POOL_TIMEOUT ?? env.DATABASE_POOL_TIMEOUT
779+
).toString(),
780+
connectTimeout: (
781+
env.RUN_OPS_DATABASE_READ_REPLICA_CONNECTION_TIMEOUT ?? env.DATABASE_CONNECTION_TIMEOUT
782+
).toString(),
733783
applicationName: env.SERVICE_NAME,
734784
});
735785

apps/webapp/app/env.server.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ const isNotInsecureSecret = (value: string) =>
108108
const INSECURE_SECRET_MESSAGE =
109109
"must not be a known-insecure published default; set a strong, unique value. If you cannot rotate it yet (e.g. it protects existing encrypted data or active sessions), set ALLOW_INSECURE_DEFAULT_SECRETS=1 to boot while you migrate.";
110110

111+
/** Optional int env var; blank/whitespace normalises to undefined (z.coerce turns "" into 0). */
112+
const OptionalIntEnv = z.preprocess(
113+
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
114+
z.coerce.number().int().optional()
115+
);
116+
111117
const EnvironmentSchema = z
112118
.object({
113119
NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]),
@@ -120,6 +126,10 @@ const EnvironmentSchema = z
120126
DATABASE_CONNECTION_LIMIT: z.coerce.number().int().default(10),
121127
DATABASE_POOL_TIMEOUT: z.coerce.number().int().default(60),
122128
DATABASE_CONNECTION_TIMEOUT: z.coerce.number().int().default(20),
129+
DATABASE_WRITER_POOL_TIMEOUT: OptionalIntEnv,
130+
DATABASE_WRITER_CONNECTION_TIMEOUT: OptionalIntEnv,
131+
DATABASE_READ_REPLICA_POOL_TIMEOUT: OptionalIntEnv,
132+
DATABASE_READ_REPLICA_CONNECTION_TIMEOUT: OptionalIntEnv,
123133
// Dashboard-agent conversation store. Cloud points this at a dedicated
124134
// database; when unset it falls back to DATABASE_URL (OSS), where
125135
// the tables live in the isolated `trigger_dashboard_agent` schema.
@@ -185,7 +195,15 @@ const EnvironmentSchema = z
185195
.refine(isValidDatabaseUrl, "RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL is invalid")
186196
.optional(),
187197
// Optional cap for the unpooled new run-ops read replica. Unset falls back to DATABASE_CONNECTION_LIMIT.
188-
RUN_OPS_DATABASE_READ_REPLICA_CONNECTION_LIMIT: z.coerce.number().int().optional(),
198+
RUN_OPS_DATABASE_READ_REPLICA_CONNECTION_LIMIT: OptionalIntEnv,
199+
RUN_OPS_DATABASE_WRITER_POOL_TIMEOUT: OptionalIntEnv,
200+
RUN_OPS_DATABASE_WRITER_CONNECTION_TIMEOUT: OptionalIntEnv,
201+
RUN_OPS_DATABASE_READ_REPLICA_POOL_TIMEOUT: OptionalIntEnv,
202+
RUN_OPS_DATABASE_READ_REPLICA_CONNECTION_TIMEOUT: OptionalIntEnv,
203+
RUN_OPS_LEGACY_DATABASE_WRITER_POOL_TIMEOUT: OptionalIntEnv,
204+
RUN_OPS_LEGACY_DATABASE_WRITER_CONNECTION_TIMEOUT: OptionalIntEnv,
205+
RUN_OPS_LEGACY_DATABASE_READ_REPLICA_POOL_TIMEOUT: OptionalIntEnv,
206+
RUN_OPS_LEGACY_DATABASE_READ_REPLICA_CONNECTION_TIMEOUT: OptionalIntEnv,
189207
// Direct DSN for applying the full @trigger.dev/database migrations to the LEGACY run-ops DB, keeping
190208
// its schema current after the control plane moves off it. Direct, not pooled — migrations never run
191209
// over a pooler. Optional; unset -> the entrypoint's legacy migrate step is skipped.

docs/self-hosting/env/webapp.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ mode: "wide"
2626
| `DATABASE_CONNECTION_LIMIT` | No | 10 | Max DB connections. |
2727
| `DATABASE_POOL_TIMEOUT` | No | 60 | DB pool timeout (s). |
2828
| `DATABASE_CONNECTION_TIMEOUT` | No | 20 | DB connect timeout (s). |
29+
| `DATABASE_WRITER_POOL_TIMEOUT` | No | `DATABASE_POOL_TIMEOUT` | Writer pool timeout (s); overrides the shared default for the writer only. |
30+
| `DATABASE_WRITER_CONNECTION_TIMEOUT` | No | `DATABASE_CONNECTION_TIMEOUT` | Writer connect timeout (s); overrides the shared default for the writer only. |
2931
| `DATABASE_READ_REPLICA_URL` | No | `DATABASE_URL` | Read-replica DB string. |
32+
| `DATABASE_READ_REPLICA_POOL_TIMEOUT` | No | `DATABASE_POOL_TIMEOUT` | Read-replica pool timeout (s); overrides the shared default for the replica only. |
33+
| `DATABASE_READ_REPLICA_CONNECTION_TIMEOUT` | No | `DATABASE_CONNECTION_TIMEOUT` | Read-replica connect timeout (s); overrides the shared default for the replica only. |
3034
| **Redis** | | | |
3135
| `REDIS_HOST` | Yes || Redis host. |
3236
| `REDIS_PORT` | Yes || Redis port. |

0 commit comments

Comments
 (0)