Skip to content

Commit 20f0ee3

Browse files
d-csclaude
andcommitted
refactor(webapp): address review feedback on shard wiring
- Make probeOrder a true reverse of precedence so the merge and probe paths agree on a duplicate id, matching the RoutingRunStore invariant. - Split resolveRunOpsPoolKnobs into a pure applyPoolKnobOverrides (tested with literal defaults, no env import) plus an env-reading defaults function. - Move the pure boot-table helpers to runOpsShardTable.ts so their test does not construct the db.server Prisma topology. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4f0d8ca commit 20f0ee3

6 files changed

Lines changed: 116 additions & 109 deletions

File tree

apps/webapp/app/db.server.ts

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
} from "./v3/runOpsMigration/splitMode.server";
3333
import { computeRunOpsSplitReadEnabled } from "./v3/runOpsMigration/runOpsSplitReadGate";
3434
import { resolveRunOpsPoolKnobs } from "./v3/runOpsPoolKnobs.server";
35+
import { buildRunOpsShardTable } from "./v3/runOpsShardTable";
3536
import {
3637
resolveShardResilience,
3738
controlPlaneTransactionResilience,
@@ -1160,32 +1161,6 @@ function redactUrlSecrets(hrefOrUrl: string | URL) {
11601161
return url.href;
11611162
}
11621163

1163-
// A host:port/db address, with NO username and NO query params — never a secret, and deliberately
1164-
// NOT an identity claim (two DSNs can share an address yet be different databases; that proof is the
1165-
// distinctness sentinel's, not this line's). Same tuple sameDatabaseTarget compares, kept in step.
1166-
export function runOpsAddressFingerprint(url: string): string {
1167-
try {
1168-
const u = new URL(url);
1169-
return `${u.hostname}:${u.port || "5432"}${u.pathname}`;
1170-
} catch {
1171-
return "unparseable";
1172-
}
1173-
}
1174-
1175-
export type RunOpsShardTableRow = { key: string; fingerprint: string; role: string };
1176-
1177-
// The resolved shard table for the boot log: one row per descriptor. An alias reports its role and
1178-
// carries no address (it shares the new store's pool).
1179-
export function buildRunOpsShardTable(
1180-
descriptors: Array<{ key: string; url?: string; aliasOf?: "new" }>
1181-
): RunOpsShardTableRow[] {
1182-
return descriptors.map((d) =>
1183-
d.aliasOf
1184-
? { key: d.key, fingerprint: "alias(new)", role: "alias(new)" }
1185-
: { key: d.key, fingerprint: runOpsAddressFingerprint(d.url ?? ""), role: "shard" }
1186-
);
1187-
}
1188-
11891164
export type { PrismaClient } from "@trigger.dev/database";
11901165

11911166
function getDatabaseSchema() {
Lines changed: 44 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { env } from "~/env.server";
22
import type { RunOpsShardKnobs } from "~/v3/runOpsShards.server";
33

4-
// Pool configuration for one run-ops store (writer + replica), resolved at the app boundary (IoC).
5-
// Every value reproduces today's run-ops builder expressions. Kept separate from db.server (which
4+
// Pool configuration for one run-ops store (writer + replica). Kept separate from db.server (which
65
// ~156 tests mock wholesale) so a new export breaks no mock.
76
export type ResolvedPoolKnobs = {
87
writerPoolTimeout: number;
@@ -17,65 +16,65 @@ export type ResolvedPoolKnobs = {
1716

1817
type Role = "new" | "legacy";
1918

20-
// Resolve the pool knobs for a run-ops role, reproducing today's builder expressions exactly.
21-
// descriptorKnobs (gen-2 shards only) override the pool fields.
22-
// Transaction resilience is a SEPARATE mechanism (resolveTransactionResilience) and is not here.
23-
export function resolveRunOpsPoolKnobs(
24-
role: Role,
25-
descriptorKnobs?: RunOpsShardKnobs
19+
// PURE: overlay a gen-2 shard's descriptor knobs on a role's resolved defaults. This holds the only
20+
// logic (per-field override), so a test drives it with literal defaults and literal overrides —
21+
// no env import, no circular assertion against the same env expression the impl reads.
22+
export function applyPoolKnobOverrides(
23+
defaults: ResolvedPoolKnobs,
24+
k?: RunOpsShardKnobs
2625
): ResolvedPoolKnobs {
27-
const k = descriptorKnobs;
26+
return {
27+
writerPoolTimeout: k?.writerPoolTimeout ?? defaults.writerPoolTimeout,
28+
writerConnectionTimeout: k?.writerConnectionTimeout ?? defaults.writerConnectionTimeout,
29+
writerDriverAdapter: k?.writerDriverAdapter ?? defaults.writerDriverAdapter,
30+
connectionLimit: k?.connectionLimit ?? defaults.connectionLimit,
31+
replicaConnectionLimit: k?.replicaConnectionLimit ?? defaults.replicaConnectionLimit,
32+
replicaPoolTimeout: k?.replicaPoolTimeout ?? defaults.replicaPoolTimeout,
33+
replicaConnectionTimeout: k?.replicaConnectionTimeout ?? defaults.replicaConnectionTimeout,
34+
replicaDriverAdapter: k?.replicaDriverAdapter ?? defaults.replicaDriverAdapter,
35+
};
36+
}
2837

38+
// The env-derived defaults for a role, reproducing today's run-ops builder expressions exactly. A
39+
// flat mapping (no logic), verified by inspection against the former builders. Transaction
40+
// resilience is a SEPARATE mechanism (resolveTransactionResilience) and is not here.
41+
function poolKnobDefaults(role: Role): ResolvedPoolKnobs {
2942
if (role === "legacy") {
3043
return {
3144
writerPoolTimeout:
32-
k?.writerPoolTimeout ??
33-
env.RUN_OPS_LEGACY_DATABASE_WRITER_POOL_TIMEOUT ??
34-
env.DATABASE_POOL_TIMEOUT,
45+
env.RUN_OPS_LEGACY_DATABASE_WRITER_POOL_TIMEOUT ?? env.DATABASE_POOL_TIMEOUT,
3546
writerConnectionTimeout:
36-
k?.writerConnectionTimeout ??
37-
env.RUN_OPS_LEGACY_DATABASE_WRITER_CONNECTION_TIMEOUT ??
38-
env.DATABASE_CONNECTION_TIMEOUT,
39-
writerDriverAdapter:
40-
k?.writerDriverAdapter ?? env.RUN_OPS_LEGACY_DATABASE_WRITER_DRIVER_ADAPTER === "1",
41-
connectionLimit: k?.connectionLimit ?? env.DATABASE_CONNECTION_LIMIT,
42-
replicaConnectionLimit: k?.replicaConnectionLimit ?? env.DATABASE_CONNECTION_LIMIT,
47+
env.RUN_OPS_LEGACY_DATABASE_WRITER_CONNECTION_TIMEOUT ?? env.DATABASE_CONNECTION_TIMEOUT,
48+
writerDriverAdapter: env.RUN_OPS_LEGACY_DATABASE_WRITER_DRIVER_ADAPTER === "1",
49+
connectionLimit: env.DATABASE_CONNECTION_LIMIT,
50+
replicaConnectionLimit: env.DATABASE_CONNECTION_LIMIT,
4351
replicaPoolTimeout:
44-
k?.replicaPoolTimeout ??
45-
env.RUN_OPS_LEGACY_DATABASE_READ_REPLICA_POOL_TIMEOUT ??
46-
env.DATABASE_POOL_TIMEOUT,
52+
env.RUN_OPS_LEGACY_DATABASE_READ_REPLICA_POOL_TIMEOUT ?? env.DATABASE_POOL_TIMEOUT,
4753
replicaConnectionTimeout:
48-
k?.replicaConnectionTimeout ??
4954
env.RUN_OPS_LEGACY_DATABASE_READ_REPLICA_CONNECTION_TIMEOUT ??
5055
env.DATABASE_CONNECTION_TIMEOUT,
51-
replicaDriverAdapter:
52-
k?.replicaDriverAdapter ?? env.RUN_OPS_LEGACY_DATABASE_REPLICA_DRIVER_ADAPTER === "1",
56+
replicaDriverAdapter: env.RUN_OPS_LEGACY_DATABASE_REPLICA_DRIVER_ADAPTER === "1",
5357
};
5458
}
5559

5660
return {
57-
writerPoolTimeout:
58-
k?.writerPoolTimeout ?? env.RUN_OPS_DATABASE_WRITER_POOL_TIMEOUT ?? env.DATABASE_POOL_TIMEOUT,
61+
writerPoolTimeout: env.RUN_OPS_DATABASE_WRITER_POOL_TIMEOUT ?? env.DATABASE_POOL_TIMEOUT,
5962
writerConnectionTimeout:
60-
k?.writerConnectionTimeout ??
61-
env.RUN_OPS_DATABASE_WRITER_CONNECTION_TIMEOUT ??
62-
env.DATABASE_CONNECTION_TIMEOUT,
63-
writerDriverAdapter:
64-
k?.writerDriverAdapter ?? env.RUN_OPS_DATABASE_WRITER_DRIVER_ADAPTER === "1",
65-
connectionLimit: k?.connectionLimit ?? env.DATABASE_CONNECTION_LIMIT,
63+
env.RUN_OPS_DATABASE_WRITER_CONNECTION_TIMEOUT ?? env.DATABASE_CONNECTION_TIMEOUT,
64+
writerDriverAdapter: env.RUN_OPS_DATABASE_WRITER_DRIVER_ADAPTER === "1",
65+
connectionLimit: env.DATABASE_CONNECTION_LIMIT,
6666
replicaConnectionLimit:
67-
k?.replicaConnectionLimit ??
68-
env.RUN_OPS_DATABASE_READ_REPLICA_CONNECTION_LIMIT ??
69-
env.DATABASE_CONNECTION_LIMIT,
70-
replicaPoolTimeout:
71-
k?.replicaPoolTimeout ??
72-
env.RUN_OPS_DATABASE_READ_REPLICA_POOL_TIMEOUT ??
73-
env.DATABASE_POOL_TIMEOUT,
67+
env.RUN_OPS_DATABASE_READ_REPLICA_CONNECTION_LIMIT ?? env.DATABASE_CONNECTION_LIMIT,
68+
replicaPoolTimeout: env.RUN_OPS_DATABASE_READ_REPLICA_POOL_TIMEOUT ?? env.DATABASE_POOL_TIMEOUT,
7469
replicaConnectionTimeout:
75-
k?.replicaConnectionTimeout ??
76-
env.RUN_OPS_DATABASE_READ_REPLICA_CONNECTION_TIMEOUT ??
77-
env.DATABASE_CONNECTION_TIMEOUT,
78-
replicaDriverAdapter:
79-
k?.replicaDriverAdapter ?? env.RUN_OPS_DATABASE_REPLICA_DRIVER_ADAPTER === "1",
70+
env.RUN_OPS_DATABASE_READ_REPLICA_CONNECTION_TIMEOUT ?? env.DATABASE_CONNECTION_TIMEOUT,
71+
replicaDriverAdapter: env.RUN_OPS_DATABASE_REPLICA_DRIVER_ADAPTER === "1",
8072
};
8173
}
74+
75+
export function resolveRunOpsPoolKnobs(
76+
role: Role,
77+
descriptorKnobs?: RunOpsShardKnobs
78+
): ResolvedPoolKnobs {
79+
return applyPoolKnobOverrides(poolKnobDefaults(role), descriptorKnobs);
80+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Pure boot-table helpers. Dependency-free (no db.server, no env) so a test of these two string
2+
// functions never constructs a Prisma client. db.server imports them for the boot log.
3+
4+
// A host:port/db address, with NO username and NO query params — never a secret, and deliberately
5+
// NOT an identity claim (two DSNs can share an address yet be different databases; that proof is the
6+
// distinctness sentinel's, not this line's). Same tuple sameDatabaseTarget compares, kept in step.
7+
export function runOpsAddressFingerprint(url: string): string {
8+
try {
9+
const u = new URL(url);
10+
return `${u.hostname}:${u.port || "5432"}${u.pathname}`;
11+
} catch {
12+
return "unparseable";
13+
}
14+
}
15+
16+
export type RunOpsShardTableRow = { key: string; fingerprint: string; role: string };
17+
18+
// The resolved shard table for the boot log: one row per descriptor. An alias reports its role and
19+
// carries no address (it shares the new store's pool).
20+
export function buildRunOpsShardTable(
21+
descriptors: Array<{ key: string; url?: string; aliasOf?: "new" }>
22+
): RunOpsShardTableRow[] {
23+
return descriptors.map((d) =>
24+
d.aliasOf
25+
? { key: d.key, fingerprint: "alias(new)", role: "alias(new)" }
26+
: { key: d.key, fingerprint: runOpsAddressFingerprint(d.url ?? ""), role: "shard" }
27+
);
28+
}

apps/webapp/app/v3/runStore.server.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,12 +124,16 @@ export function buildRunStore(deps: BuildRunStoreDeps): RunStore {
124124
...shardStores.map(({ key, store }) => [key, store] as const),
125125
]);
126126

127+
// Ascending authority for a merge: legacy -> new -> shards in configured order. The router
128+
// requires probeOrder to be the exact reverse (see the class invariant in runOpsStore.ts), so a
129+
// duplicate id resolves the same way on the merge path and the probe path.
130+
const precedence: ShardKey[] = ["legacy", "new", ...shardKeys];
131+
const probeOrder = [...precedence].reverse();
132+
127133
return RoutingRunStore.fromShards({
128134
shards: shardMap,
129-
// Ascending authority for a merge: legacy -> new -> shards in configured order.
130-
precedence: ["legacy", "new", ...shardKeys],
131-
// Probe order for an id-less lookup: the reverse of precedence.
132-
probeOrder: ["new", ...shardKeys, "legacy"],
135+
precedence,
136+
probeOrder,
133137
idlessRouteShard: "new",
134138
idlessWaitpointShard: "legacy",
135139
resolveShardKey: deps.resolveShardKey ?? resolveShard,
Lines changed: 34 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,42 @@
11
import { describe, expect, it } from "vitest";
2-
import { resolveRunOpsPoolKnobs } from "~/v3/runOpsPoolKnobs.server";
3-
import { env } from "~/env.server";
2+
import { applyPoolKnobOverrides, type ResolvedPoolKnobs } from "~/v3/runOpsPoolKnobs.server";
43

5-
describe("resolveRunOpsPoolKnobs", () => {
6-
it("new role: reproduces the run-ops builder expressions", () => {
7-
const k = resolveRunOpsPoolKnobs("new");
8-
expect(k.connectionLimit).toBe(env.DATABASE_CONNECTION_LIMIT);
9-
expect(k.replicaConnectionLimit).toBe(
10-
env.RUN_OPS_DATABASE_READ_REPLICA_CONNECTION_LIMIT ?? env.DATABASE_CONNECTION_LIMIT
11-
);
12-
expect(k.writerPoolTimeout).toBe(
13-
env.RUN_OPS_DATABASE_WRITER_POOL_TIMEOUT ?? env.DATABASE_POOL_TIMEOUT
14-
);
15-
expect(k.replicaPoolTimeout).toBe(
16-
env.RUN_OPS_DATABASE_READ_REPLICA_POOL_TIMEOUT ?? env.DATABASE_POOL_TIMEOUT
17-
);
18-
expect(k.writerDriverAdapter).toBe(env.RUN_OPS_DATABASE_WRITER_DRIVER_ADAPTER === "1");
19-
expect(k.replicaDriverAdapter).toBe(env.RUN_OPS_DATABASE_REPLICA_DRIVER_ADAPTER === "1");
4+
// Literal defaults, so the assertions lock the override logic against fixed values rather than
5+
// against the same env expression the implementation reads. No env import (webapp test rule).
6+
const DEFAULTS: ResolvedPoolKnobs = {
7+
writerPoolTimeout: 10,
8+
writerConnectionTimeout: 20,
9+
writerDriverAdapter: false,
10+
connectionLimit: 30,
11+
replicaConnectionLimit: 40,
12+
replicaPoolTimeout: 50,
13+
replicaConnectionTimeout: 60,
14+
replicaDriverAdapter: false,
15+
};
16+
17+
describe("applyPoolKnobOverrides", () => {
18+
it("returns the defaults verbatim when no descriptor knobs are given", () => {
19+
expect(applyPoolKnobOverrides(DEFAULTS)).toEqual(DEFAULTS);
20+
expect(applyPoolKnobOverrides(DEFAULTS, {})).toEqual(DEFAULTS);
2021
});
2122

22-
it("legacy role: uses RUN_OPS_LEGACY_* timeouts and the generic connection limit", () => {
23-
const k = resolveRunOpsPoolKnobs("legacy");
24-
expect(k.connectionLimit).toBe(env.DATABASE_CONNECTION_LIMIT);
25-
expect(k.replicaConnectionLimit).toBe(env.DATABASE_CONNECTION_LIMIT);
26-
expect(k.writerPoolTimeout).toBe(
27-
env.RUN_OPS_LEGACY_DATABASE_WRITER_POOL_TIMEOUT ?? env.DATABASE_POOL_TIMEOUT
28-
);
29-
expect(k.replicaPoolTimeout).toBe(
30-
env.RUN_OPS_LEGACY_DATABASE_READ_REPLICA_POOL_TIMEOUT ?? env.DATABASE_POOL_TIMEOUT
31-
);
32-
expect(k.writerDriverAdapter).toBe(env.RUN_OPS_LEGACY_DATABASE_WRITER_DRIVER_ADAPTER === "1");
33-
expect(k.replicaDriverAdapter).toBe(env.RUN_OPS_LEGACY_DATABASE_REPLICA_DRIVER_ADAPTER === "1");
23+
it("overrides only the fields the descriptor sets", () => {
24+
const result = applyPoolKnobOverrides(DEFAULTS, {
25+
connectionLimit: 999,
26+
writerDriverAdapter: true,
27+
replicaPoolTimeout: 555,
28+
});
29+
expect(result.connectionLimit).toBe(999);
30+
expect(result.writerDriverAdapter).toBe(true);
31+
expect(result.replicaPoolTimeout).toBe(555);
32+
// Untouched fields keep the defaults.
33+
expect(result.writerPoolTimeout).toBe(10);
34+
expect(result.replicaConnectionLimit).toBe(40);
35+
expect(result.replicaDriverAdapter).toBe(false);
3436
});
3537

36-
it("a descriptor knob overrides its field", () => {
37-
const k = resolveRunOpsPoolKnobs("new", { connectionLimit: 7, writerDriverAdapter: true });
38-
expect(k.connectionLimit).toBe(7);
39-
expect(k.writerDriverAdapter).toBe(true);
38+
it("does not read the transaction knobs off the descriptor", () => {
39+
const result = applyPoolKnobOverrides(DEFAULTS, { transactionMaxWaitMs: 1234 });
40+
expect(result).toEqual(DEFAULTS);
4041
});
4142
});

apps/webapp/test/runOpsShardBootTable.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { runOpsAddressFingerprint, buildRunOpsShardTable } from "~/db.server";
2+
import { runOpsAddressFingerprint, buildRunOpsShardTable } from "~/v3/runOpsShardTable";
33

44
describe("runOpsAddressFingerprint", () => {
55
it("returns host:port/db with no username or query params", () => {

0 commit comments

Comments
 (0)