Skip to content

Commit 5b6f9f1

Browse files
d-csTrigger.dev RepoOps
authored andcommitted
feat(webapp): optional max lifetime for pooled database connections
Database connections can now be given a maximum lifetime via `DATABASE_MAX_CONNECTION_LIFETIME` (seconds), so a pooled connection is retired and replaced during ordinary use instead of being held for as long as the process lives. Set it when something upstream of the app, such as a connection pooler or proxy, retires long-lived connections on its own schedule: recycling below that threshold means connections move to a replacement during normal traffic rather than being severed mid-request. Applies to every database client the webapp builds, on both the default query engine and the node-postgres driver adapter, and each pool picks a slightly different expiry so they do not all reconnect at the same moment. Unset leaves connection lifetime uncapped, which is the previous behaviour. Mono-RevId: c0c172f1c4d42320aa7494bb476630bf3fa380d4
1 parent fb25c01 commit 5b6f9f1

4 files changed

Lines changed: 161 additions & 2 deletions

File tree

‎apps/webapp/app/db.server.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ import invariant from "tiny-invariant";
1818
import { env } from "./env.server";
1919
import { logger } from "./services/logger.server";
2020
import { isValidDatabaseUrl } from "./utils/db";
21-
import { buildPrismaConnectionUrl } from "./utils/prismaConnectionUrl";
21+
import {
22+
buildPrismaConnectionUrl,
23+
connectionLifetimePoolOptions,
24+
resolveConnectionLifetimeSeconds,
25+
} from "./utils/prismaConnectionUrl";
2226
import {
2327
captureInfrastructureErrors,
2428
infraErrorAlreadyLogged,
@@ -670,11 +674,13 @@ function buildDriverAdapterPool(
670674
poolTimeoutSeconds: number,
671675
connectionLimit: number
672676
): DriverAdapterPool {
677+
const lifetimeSeconds = resolveConnectionLifetimeSeconds(env.DATABASE_MAX_CONNECTION_LIFETIME);
673678
const pool = new Pool({
674679
connectionString,
675680
max: connectionLimit,
676681
connectionTimeoutMillis: poolTimeoutSeconds * 1000,
677682
application_name: env.SERVICE_NAME,
683+
...connectionLifetimePoolOptions(lifetimeSeconds),
678684
});
679685
pool.on("error", (error) => {
680686
logger.error("prisma driver adapter pool error", {
@@ -730,6 +736,9 @@ export function buildWriterClient({
730736
poolTimeout: (poolTimeout ?? env.DATABASE_POOL_TIMEOUT).toString(),
731737
connectTimeout: (connectTimeout ?? env.DATABASE_CONNECTION_TIMEOUT).toString(),
732738
applicationName: env.SERVICE_NAME,
739+
maxConnectionLifetime: resolveConnectionLifetimeSeconds(
740+
env.DATABASE_MAX_CONNECTION_LIFETIME
741+
)?.toString(),
733742
});
734743

735744
console.log(
@@ -916,6 +925,9 @@ export function buildReplicaClient({
916925
poolTimeout: (poolTimeout ?? env.DATABASE_POOL_TIMEOUT).toString(),
917926
connectTimeout: (connectTimeout ?? env.DATABASE_CONNECTION_TIMEOUT).toString(),
918927
applicationName: env.SERVICE_NAME,
928+
maxConnectionLifetime: resolveConnectionLifetimeSeconds(
929+
env.DATABASE_MAX_CONNECTION_LIFETIME
930+
)?.toString(),
919931
});
920932

921933
console.log(
@@ -1092,6 +1104,9 @@ function buildRunOpsClient({
10921104
poolTimeout: poolTimeout.toString(),
10931105
connectTimeout: connectTimeout.toString(),
10941106
applicationName: env.SERVICE_NAME,
1107+
maxConnectionLifetime: resolveConnectionLifetimeSeconds(
1108+
env.DATABASE_MAX_CONNECTION_LIFETIME
1109+
)?.toString(),
10951110
});
10961111

10971112
console.log(

‎apps/webapp/app/env.server.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ const EnvironmentSchema = z
184184
DATABASE_WRITER_CONNECTION_TIMEOUT: OptionalIntEnv,
185185
DATABASE_READ_REPLICA_POOL_TIMEOUT: OptionalIntEnv,
186186
DATABASE_READ_REPLICA_CONNECTION_TIMEOUT: OptionalIntEnv,
187+
// Max age of a pooled connection, in seconds. Unset or 0 leaves it uncapped.
188+
DATABASE_MAX_CONNECTION_LIFETIME: OptionalLimitEnv,
187189
DATABASE_TRANSACTION_MAX_WAIT_MS: IntEnvWithDefault(10000),
188190
DATABASE_TRANSACTION_START_RETRY_ENABLED: BoolEnvWithDefault(true),
189191
DATABASE_TRANSACTION_START_RETRY_MAX_ATTEMPTS: IntEnvWithDefault(3),

‎apps/webapp/app/utils/prismaConnectionUrl.test.ts‎

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, it } from "vitest";
2-
import { buildPrismaConnectionUrl } from "./prismaConnectionUrl";
2+
import {
3+
buildPrismaConnectionUrl,
4+
connectionLifetimePoolOptions,
5+
resolveConnectionLifetimeSeconds,
6+
} from "./prismaConnectionUrl";
37

48
describe("buildPrismaConnectionUrl", () => {
59
it("sets connect_timeout (the Postgres connector parameter), not the ignored connection_timeout", () => {
@@ -27,4 +31,105 @@ describe("buildPrismaConnectionUrl", () => {
2731
expect(url.searchParams.get("sslmode")).toBe("require");
2832
expect(url.searchParams.get("connect_timeout")).toBe("20");
2933
});
34+
35+
// The inertness guard: with the lifetime unconfigured the DSN must be exactly
36+
// what it was before this parameter existed. Make the searchParams.set
37+
// unconditional and this goes red on the literal string "undefined".
38+
it("omits max_connection_lifetime entirely when it is not configured", () => {
39+
const base = { connectionLimit: "5", poolTimeout: "10", connectTimeout: "20" };
40+
41+
const withoutKey = buildPrismaConnectionUrl(
42+
"postgresql://u:p@host:5432/db?schema=public&sslmode=require",
43+
{ ...base, applicationName: "svc" }
44+
);
45+
const withUndefined = buildPrismaConnectionUrl(
46+
"postgresql://u:p@host:5432/db?schema=public&sslmode=require",
47+
{ ...base, applicationName: "svc", maxConnectionLifetime: undefined }
48+
);
49+
50+
expect(withoutKey.searchParams.has("max_connection_lifetime")).toBe(false);
51+
expect(withUndefined.searchParams.has("max_connection_lifetime")).toBe(false);
52+
expect(withUndefined.href).toBe(withoutKey.href);
53+
expect(withoutKey.href).toBe(
54+
"postgresql://u:p@host:5432/db?schema=public&sslmode=require&connection_limit=5&pool_timeout=10&connect_timeout=20&application_name=svc"
55+
);
56+
});
57+
58+
// Pins the contract as "omit iff undefined": any supplied string is passed
59+
// through, so the caller decides, not this builder.
60+
it("passes through an explicitly supplied zero", () => {
61+
const url = buildPrismaConnectionUrl("postgresql://u:p@host:5432/db", {
62+
connectionLimit: "5",
63+
poolTimeout: "10",
64+
connectTimeout: "20",
65+
applicationName: "svc",
66+
maxConnectionLifetime: "0",
67+
});
68+
69+
expect(url.searchParams.get("max_connection_lifetime")).toBe("0");
70+
});
71+
72+
it("sets max_connection_lifetime when configured", () => {
73+
const url = buildPrismaConnectionUrl("postgresql://u:p@host:5432/db", {
74+
connectionLimit: "5",
75+
poolTimeout: "10",
76+
connectTimeout: "20",
77+
applicationName: "svc",
78+
maxConnectionLifetime: "3600",
79+
});
80+
81+
expect(url.searchParams.get("max_connection_lifetime")).toBe("3600");
82+
});
83+
});
84+
85+
describe("resolveConnectionLifetimeSeconds", () => {
86+
it.each([
87+
["unset", undefined],
88+
["zero", 0],
89+
["negative", -1],
90+
["NaN", Number.NaN],
91+
["Infinity", Number.POSITIVE_INFINITY],
92+
])("returns undefined when the base is %s", (_label, base) => {
93+
expect(resolveConnectionLifetimeSeconds(base, () => 0.5)).toBeUndefined();
94+
});
95+
96+
it("returns the base with no jitter at the bottom of the random range", () => {
97+
expect(resolveConnectionLifetimeSeconds(3600, () => 0)).toBe(3600);
98+
});
99+
100+
it("subtracts at most 20% jitter at the top of the random range", () => {
101+
expect(resolveConnectionLifetimeSeconds(3600, () => 1)).toBe(2880);
102+
});
103+
104+
it("clamps an out-of-range random into [base * 0.8, base]", () => {
105+
expect(resolveConnectionLifetimeSeconds(3600, () => -5)).toBe(3600);
106+
expect(resolveConnectionLifetimeSeconds(3600, () => 5)).toBe(2880);
107+
expect(resolveConnectionLifetimeSeconds(3600, () => Number.NaN)).toBe(3600);
108+
});
109+
110+
// The configured value is a maximum: an operator may set it just below an
111+
// upstream cutoff, so jitter must never push a connection past it. Flip the
112+
// sign in resolveConnectionLifetimeSeconds and this goes red.
113+
it.each([1, 2, 60, 3600, 82800])("never exceeds the configured cap of %i", (base) => {
114+
for (const r of [0, 0.25, 0.5, 0.75, 1]) {
115+
const resolved = resolveConnectionLifetimeSeconds(base, () => r);
116+
expect(resolved).toBeLessThanOrEqual(base);
117+
expect(resolved).toBeGreaterThanOrEqual(1);
118+
}
119+
});
120+
});
121+
122+
describe("connectionLifetimePoolOptions", () => {
123+
// Omitting the key is what keeps an unconfigured deployment inert, so assert
124+
// absence rather than an undefined value.
125+
it("omits maxLifetimeSeconds entirely when uncapped", () => {
126+
const options = connectionLifetimePoolOptions(undefined);
127+
128+
expect("maxLifetimeSeconds" in options).toBe(false);
129+
expect(Object.keys(options)).toHaveLength(0);
130+
});
131+
132+
it("carries maxLifetimeSeconds when a lifetime is resolved", () => {
133+
expect(connectionLifetimePoolOptions(3600)).toEqual({ maxLifetimeSeconds: 3600 });
134+
});
30135
});

‎apps/webapp/app/utils/prismaConnectionUrl.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ export type PrismaConnectionParams = {
33
poolTimeout: string;
44
connectTimeout: string;
55
applicationName: string;
6+
/** `max_connection_lifetime` (seconds); omitted from the URL when undefined. */
7+
maxConnectionLifetime?: string;
68
};
79

810
export function buildPrismaConnectionUrl(
@@ -14,5 +16,40 @@ export function buildPrismaConnectionUrl(
1416
url.searchParams.set("pool_timeout", params.poolTimeout);
1517
url.searchParams.set("connect_timeout", params.connectTimeout);
1618
url.searchParams.set("application_name", params.applicationName);
19+
// Omit iff undefined, so an unconfigured deployment gets a byte-identical DSN.
20+
// Setting it unconditionally would write the string "undefined" into the URL.
21+
if (params.maxConnectionLifetime !== undefined) {
22+
url.searchParams.set("max_connection_lifetime", params.maxConnectionLifetime);
23+
}
1724
return url;
1825
}
26+
27+
/**
28+
* Per-pool connection lifetime in seconds, or undefined for "leave it uncapped".
29+
* Subtracts 0-20% jitter, drawn once per pool, so pools built in one go do not
30+
* expire their connections in lockstep. Jitter only ever shortens the lifetime:
31+
* `base` is a maximum, and an operator may set it just under an upstream cutoff.
32+
* Pure (base and randomness injected) so it is testable without env.
33+
*/
34+
export function resolveConnectionLifetimeSeconds(
35+
base: number | undefined,
36+
random: () => number = Math.random
37+
): number | undefined {
38+
if (base === undefined || !Number.isFinite(base) || base <= 0) {
39+
return undefined;
40+
}
41+
42+
const rand = random();
43+
const r = Number.isFinite(rand) ? Math.min(1, Math.max(0, rand)) : 0;
44+
return Math.max(1, Math.round(base - r * base * 0.2));
45+
}
46+
47+
/**
48+
* Pool options carrying the lifetime, or an empty object when uncapped. Omitting
49+
* the key keeps an unconfigured deployment off pg-pool's `|| 0` coercion path.
50+
*/
51+
export function connectionLifetimePoolOptions(lifetimeSeconds: number | undefined): {
52+
maxLifetimeSeconds?: number;
53+
} {
54+
return lifetimeSeconds === undefined ? {} : { maxLifetimeSeconds: lifetimeSeconds };
55+
}

0 commit comments

Comments
 (0)