Skip to content

Commit a1ca646

Browse files
authored
fix(webapp): reuse the primary db pool for legacy run-ops when DSNs match (#4253)
## Summary When the run-ops split is enabled, the legacy run-ops database client was always constructed as its own connection pool, even when it points at the same database as the primary (control-plane) client. On setups where those two DSNs resolve to the same physical database, this opened a second, redundant pool and doubled the number of connections used against that database. This change makes the legacy client reuse the primary client's pool whenever their DSNs point at the same database, and only open a separate pool when they genuinely differ. ## Fix A small `sameDatabaseTarget` comparison (host, port, database name, user) decides whether the legacy DSN points at the same database as the primary. When it does, the legacy handle reuses the primary client by reference, so no second pool is opened. When the DSNs diverge, the legacy client is built independently as before, so the split still works once the databases are actually separate. Two smaller changes ride along: - An optional per-pool limit for the run-ops read replica, which connects unpooled and so draws raw backend connections; unset, it falls back to the existing default and behaviour is unchanged. - A startup warning about a missing legacy replica URL is now suppressed when the legacy client shares the primary pool, where it would be misleading. ## Verification Booted the webapp end-to-end in three modes and confirmed the pools opened as expected via the client's own startup logs and live backend connection counts: split off (single pool), split on with a shared database (legacy reuses the primary pool, no doubling), and split on with separate databases (legacy opens its own pool).
1 parent 1659557 commit a1ca646

4 files changed

Lines changed: 181 additions & 18 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: fix
4+
---
5+
6+
Avoid opening a redundant database connection pool when the legacy and primary databases are the same server, preventing connection usage from doubling.

apps/webapp/app/db.server.ts

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ export type SelectRunOpsTopologyConfig = {
192192
legacyReplicaUrl?: string;
193193
newUrl?: string;
194194
newReplicaUrl?: string;
195+
// When true, legacy reuses the control-plane client instead of opening its own pool. Defaults to false.
196+
legacySharesControlPlane?: boolean;
195197
};
196198
export type RunOpsClientBuilders = {
197199
controlPlane: RunOpsClients;
@@ -226,15 +228,17 @@ export function selectRunOpsTopology(
226228
return { newRunOps: cpFallback, legacyRunOps: controlPlane, controlPlane };
227229
}
228230

229-
// Track 2: build an INDEPENDENT legacy client from its own DSN instead of aliasing the control
230-
// plane. legacyUrl is guaranteed present (the missing-URL branch above aliases and returns).
231-
const legacyWriter = builders.buildLegacyWriter(config.legacyUrl, "run-ops-legacy-writer");
232-
// Mirror the NEW replica + control-plane $replica fallback: brand a real replica (in the builder),
233-
// otherwise reuse the legacy WRITER so replica reads fall back to the legacy primary — unbranded.
234-
const legacyReplica: PrismaReplicaClient = config.legacyReplicaUrl
235-
? builders.buildLegacyReplica(config.legacyReplicaUrl, "run-ops-legacy-reader")
236-
: legacyWriter;
237-
const legacyRunOps: RunOpsClients = { writer: legacyWriter, replica: legacyReplica };
231+
// Same-DB legacy reuses the control-plane pool; only build a separate pool once the DSNs diverge.
232+
let legacyRunOps: RunOpsClients;
233+
if (config.legacySharesControlPlane) {
234+
legacyRunOps = controlPlane;
235+
} else {
236+
const legacyWriter = builders.buildLegacyWriter(config.legacyUrl, "run-ops-legacy-writer");
237+
const legacyReplica: PrismaReplicaClient = config.legacyReplicaUrl
238+
? builders.buildLegacyReplica(config.legacyReplicaUrl, "run-ops-legacy-reader")
239+
: legacyWriter;
240+
legacyRunOps = { writer: legacyWriter, replica: legacyReplica };
241+
}
238242

239243
const newWriter = builders.buildNewWriter(config.newUrl, "run-ops-new-writer");
240244
const newReplica: RunOpsPrismaClient = config.newReplicaUrl
@@ -260,9 +264,19 @@ const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => {
260264
// Gate on the opt-in flag too: the distinct-DB sentinel only runs when the flag is on.
261265
const splitEnabled = env.RUN_OPS_SPLIT_ENABLED && !!newUrl && !!env.RUN_OPS_LEGACY_DATABASE_URL;
262266

263-
// Without a dedicated legacy replica URL, legacy reads fall back to the legacy WRITER (primary).
264-
// Surface that so a prod misdeploy is observable instead of a silent load shift onto the primary.
265-
if (splitEnabled && !env.RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL) {
267+
// Alias legacy onto the control-plane pool when both roles resolve to the same DB (replica URLs
268+
// fall back to their writer, matching how the clients themselves fall back).
269+
const cpWriterUrl = env.CONTROL_PLANE_DATABASE_URL ?? env.DATABASE_URL;
270+
const cpReplicaUrl = env.CONTROL_PLANE_DATABASE_READ_REPLICA_URL ?? env.DATABASE_READ_REPLICA_URL;
271+
const legacySharesControlPlane =
272+
sameDatabaseTarget(env.RUN_OPS_LEGACY_DATABASE_URL, cpWriterUrl) &&
273+
sameDatabaseTarget(
274+
env.RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL ?? env.RUN_OPS_LEGACY_DATABASE_URL,
275+
cpReplicaUrl ?? cpWriterUrl
276+
);
277+
278+
// Only meaningful for an independent legacy pool; a shared pool routes reads through $replica.
279+
if (splitEnabled && !legacySharesControlPlane && !env.RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL) {
266280
logger.warn(
267281
"RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL is unset while split is enabled; legacy reads will hit the legacy primary"
268282
);
@@ -275,6 +289,7 @@ const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => {
275289
legacyReplicaUrl: env.RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL,
276290
newUrl,
277291
newReplicaUrl: env.RUN_OPS_DATABASE_READ_REPLICA_URL,
292+
legacySharesControlPlane,
278293
},
279294
{
280295
controlPlane: { writer: prisma, replica: $replica },
@@ -709,7 +724,10 @@ function buildRunOpsReplicaClient({
709724
clientType: string;
710725
}): RunOpsPrismaClient {
711726
const replicaUrl = extendQueryParams(url, {
712-
connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(),
727+
// The new run-ops replica connects unpooled, so allow capping it independently of the writer.
728+
connection_limit: (
729+
env.RUN_OPS_DATABASE_READ_REPLICA_CONNECTION_LIMIT ?? env.DATABASE_CONNECTION_LIMIT
730+
).toString(),
713731
pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(),
714732
connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(),
715733
application_name: env.SERVICE_NAME,
@@ -752,6 +770,25 @@ function buildRunOpsReplicaClient({
752770
return client;
753771
}
754772

773+
// True when two DSNs point at the same database (host/port/dbname/user), ignoring query params and
774+
// password. Parse failure or a missing URL returns false, so an unrecognized DSN just isn't aliased.
775+
export function sameDatabaseTarget(a: string | undefined, b: string | undefined): boolean {
776+
if (!a || !b) return false;
777+
try {
778+
const ua = new URL(a);
779+
const ub = new URL(b);
780+
const port = (u: URL) => u.port || "5432";
781+
return (
782+
ua.hostname.toLowerCase() === ub.hostname.toLowerCase() &&
783+
port(ua) === port(ub) &&
784+
ua.pathname === ub.pathname &&
785+
ua.username === ub.username
786+
);
787+
} catch {
788+
return false;
789+
}
790+
}
791+
755792
function extendQueryParams(hrefOrUrl: string | URL, queryParams: Record<string, string>) {
756793
const url = new URL(hrefOrUrl);
757794
const query = url.searchParams;

apps/webapp/app/env.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ const EnvironmentSchema = z
159159
.string()
160160
.refine(isValidDatabaseUrl, "RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL is invalid")
161161
.optional(),
162+
// Optional cap for the unpooled new run-ops read replica. Unset falls back to DATABASE_CONNECTION_LIMIT.
163+
RUN_OPS_DATABASE_READ_REPLICA_CONNECTION_LIMIT: z.coerce.number().int().optional(),
162164
// Direct DSN for applying the full @trigger.dev/database migrations to the LEGACY run-ops DB, keeping
163165
// its schema current after the control plane moves off it. Direct, not pooled — migrations never run
164166
// over a pooler. Optional; unset -> the entrypoint's legacy migrate step is skipped.

apps/webapp/test/runOpsDbTopology.test.ts

Lines changed: 123 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { PostgreSqlContainer } from "@testcontainers/postgresql";
22
import { describe, expect, it, vi } from "vitest";
3-
import { buildReplicaClient, buildWriterClient, selectRunOpsTopology } from "~/db.server";
3+
import {
4+
buildReplicaClient,
5+
buildWriterClient,
6+
sameDatabaseTarget,
7+
selectRunOpsTopology,
8+
} from "~/db.server";
49

510
const cp = { writer: {} as any, replica: {} as any };
611

@@ -44,7 +49,34 @@ describe("selectRunOpsTopology (pure)", () => {
4449
expect(buildLegacyWriter).not.toHaveBeenCalled();
4550
});
4651

47-
it("split ON: legacy builds its OWN writer + replica (Track 2: no longer aliased to control-plane)", () => {
52+
it("split ON + legacySharesControlPlane: aliases legacy to control-plane and builds NO legacy client", () => {
53+
const newWriter = { tag: "nw" } as any;
54+
const newReplica = { tag: "nr" } as any;
55+
const buildNewWriter = vi.fn().mockReturnValue(newWriter);
56+
const buildNewReplica = vi.fn().mockReturnValue(newReplica);
57+
const buildLegacyWriter = vi.fn();
58+
const buildLegacyReplica = vi.fn();
59+
const topo = selectRunOpsTopology(
60+
{
61+
splitEnabled: true,
62+
legacyUrl: "postgres://same",
63+
legacyReplicaUrl: "postgres://same-r",
64+
newUrl: "postgres://new",
65+
newReplicaUrl: "postgres://new-r",
66+
legacySharesControlPlane: true,
67+
},
68+
{ controlPlane: cp, buildNewWriter, buildNewReplica, buildLegacyWriter, buildLegacyReplica }
69+
);
70+
// Legacy reuses the control-plane pair by reference — no second pool against the same server.
71+
expect(topo.legacyRunOps).toBe(cp);
72+
expect(buildLegacyWriter).not.toHaveBeenCalled();
73+
expect(buildLegacyReplica).not.toHaveBeenCalled();
74+
// New run-ops still builds its own (independent) client.
75+
expect(topo.newRunOps.writer).toBe(newWriter);
76+
expect(topo.newRunOps.replica).toBe(newReplica);
77+
});
78+
79+
it("split ON (flag off): legacy builds its OWN writer + replica (independent, not aliased)", () => {
4880
const newWriter = { tag: "nw" } as any;
4981
const newReplica = { tag: "nr" } as any;
5082
const legacyWriter = { tag: "lw" } as any;
@@ -112,6 +144,49 @@ describe("selectRunOpsTopology (pure)", () => {
112144
});
113145
});
114146

147+
describe("sameDatabaseTarget", () => {
148+
it("same host/port/db/user is a match despite differing query params and password", () => {
149+
expect(
150+
sameDatabaseTarget(
151+
"postgresql://user:secret1@db.internal:5432/trigger?connection_limit=10&application_name=api",
152+
"postgresql://user:secret2@db.internal:5432/trigger?connection_limit=55"
153+
)
154+
).toBe(true);
155+
});
156+
157+
it("treats a missing port as the default 5432", () => {
158+
expect(
159+
sameDatabaseTarget(
160+
"postgresql://user@db.internal/trigger",
161+
"postgresql://user@db.internal:5432/trigger"
162+
)
163+
).toBe(true);
164+
});
165+
166+
it("differs on host, port, dbname, or user", () => {
167+
const base = "postgresql://user@db.internal:5432/trigger";
168+
expect(sameDatabaseTarget(base, "postgresql://user@other.internal:5432/trigger")).toBe(false);
169+
expect(sameDatabaseTarget(base, "postgresql://user@db.internal:6432/trigger")).toBe(false);
170+
expect(sameDatabaseTarget(base, "postgresql://user@db.internal:5432/other")).toBe(false);
171+
expect(sameDatabaseTarget(base, "postgresql://other@db.internal:5432/trigger")).toBe(false);
172+
});
173+
174+
it("returns false for undefined or unparseable input", () => {
175+
expect(sameDatabaseTarget(undefined, "postgresql://user@db/trigger")).toBe(false);
176+
expect(sameDatabaseTarget("postgresql://user@db/trigger", undefined)).toBe(false);
177+
expect(sameDatabaseTarget("not a url", "also not a url")).toBe(false);
178+
});
179+
180+
it("matches the prod-shaped legacy-vs-cp writer pair, not the new replica", () => {
181+
const cpWriter = "postgresql://master:pw@rds-writer.internal:5432/pgtrigger";
182+
const legacyWriter =
183+
"postgresql://master:pw@rds-writer.internal:5432/pgtrigger?connection_limit=25";
184+
const newReplica = "postgresql://master%7Creplica:pw@ps-host.internal:5432/pgtrigger";
185+
expect(sameDatabaseTarget(cpWriter, legacyWriter)).toBe(true);
186+
expect(sameDatabaseTarget(cpWriter, newReplica)).toBe(false);
187+
});
188+
});
189+
115190
describe("selectRunOpsTopology (integration, real containers)", () => {
116191
it("split OFF: opens exactly one DB; all run-ops handles share the control-plane client", async () => {
117192
const pg = await new PostgreSqlContainer("docker.io/postgres:14").start();
@@ -152,7 +227,7 @@ describe("selectRunOpsTopology (integration, real containers)", () => {
152227
}
153228
}, 60_000);
154229

155-
it("split ON: constructs CP + INDEPENDENT legacy-run-ops + new-run-ops + replicas", async () => {
230+
it("split ON (flag off): constructs CP + INDEPENDENT legacy-run-ops + new-run-ops + replicas", async () => {
156231
const rds = await new PostgreSqlContainer("docker.io/postgres:14").start();
157232
const ps = await new PostgreSqlContainer("docker.io/postgres:17").start();
158233
try {
@@ -161,8 +236,8 @@ describe("selectRunOpsTopology (integration, real containers)", () => {
161236
const topo = selectRunOpsTopology(
162237
{
163238
splitEnabled: true,
164-
// Same-DSN stage: legacy points at the same physical DB as the control plane, but the
165-
// client is an INDEPENDENT instance (its own pool) — never the cp object.
239+
// Divergent-DB stage (legacySharesControlPlane omitted): legacy builds an INDEPENDENT
240+
// client with its own pool — never the cp object.
166241
legacyUrl: rds.getConnectionUri(),
167242
legacyReplicaUrl: rds.getConnectionUri(),
168243
newUrl: ps.getConnectionUri(),
@@ -195,4 +270,47 @@ describe("selectRunOpsTopology (integration, real containers)", () => {
195270
await ps.stop();
196271
}
197272
}, 120_000);
273+
274+
it("split ON + legacySharesControlPlane: legacy reuses the CP pool, only the new DB opens a client", async () => {
275+
const rds = await new PostgreSqlContainer("docker.io/postgres:14").start();
276+
const ps = await new PostgreSqlContainer("docker.io/postgres:17").start();
277+
try {
278+
const cpWriter = buildWriterClient({ url: rds.getConnectionUri(), clientType: "cp" });
279+
const cp = { writer: cpWriter, replica: cpWriter };
280+
const legacyBuilds: string[] = [];
281+
const topo = selectRunOpsTopology(
282+
{
283+
splitEnabled: true,
284+
legacyUrl: rds.getConnectionUri(),
285+
legacyReplicaUrl: rds.getConnectionUri(),
286+
newUrl: ps.getConnectionUri(),
287+
legacySharesControlPlane: true,
288+
},
289+
{
290+
controlPlane: cp,
291+
buildNewWriter: (url, ct) => buildWriterClient({ url, clientType: ct }) as any,
292+
buildNewReplica: (url, ct) => buildReplicaClient({ url, clientType: ct }) as any,
293+
buildLegacyWriter: (url, ct) => {
294+
legacyBuilds.push(url);
295+
return buildWriterClient({ url, clientType: ct });
296+
},
297+
buildLegacyReplica: (url, ct) => {
298+
legacyBuilds.push(url);
299+
return buildReplicaClient({ url, clientType: ct });
300+
},
301+
}
302+
);
303+
expect(legacyBuilds).toHaveLength(0); // no redundant legacy pool against the shared server
304+
expect(topo.legacyRunOps).toBe(cp);
305+
expect(topo.legacyRunOps.writer).toBe(cpWriter);
306+
expect(topo.newRunOps.writer).not.toBe(cpWriter);
307+
await topo.legacyRunOps.writer.$queryRawUnsafe("SELECT 1"); // legacy queries run on the CP pool
308+
await topo.newRunOps.writer.$queryRawUnsafe("SELECT 1");
309+
await cpWriter.$disconnect();
310+
await topo.newRunOps.writer.$disconnect();
311+
} finally {
312+
await rds.stop();
313+
await ps.stop();
314+
}
315+
}, 120_000);
198316
});

0 commit comments

Comments
 (0)