Skip to content

Commit 9591de6

Browse files
committed
fix(webapp,rbac): retry branch environment lookups that race replica lag
The first deploy of a newly created branch upserts the branch environment and immediately authenticates with it, so the auth-time replica read can miss the just-committed row and the request fails. Branch lookups in API auth (PAT and OAT resolution, the RBAC bearer resolver, and the legacy API key resolver) now retry the replica once with jitter and fall back to the primary before reporting the branch missing. Installs without a dedicated read replica skip the retry entirely.
1 parent 43ecf15 commit 9591de6

7 files changed

Lines changed: 256 additions & 34 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+
Fixed a race that could make the first deploy of a newly created preview branch fail and eventually time out. Deploys to just-created branches now resolve reliably.

apps/webapp/app/models/runtimeEnvironment.server.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.
66
import { logger } from "~/services/logger.server";
77
import { getUsername } from "~/utils/username";
88
import { hashApiKey } from "~/utils/apiKeys";
9+
import { findWithReplicaRetry } from "~/services/replicaLagRetry.server";
10+
import { observeBranchEnvironmentReplicaMiss } from "~/services/authTelemetry.server";
11+
import { isReadReplicaClient } from "@internal/run-store";
912
import { BuildRuntime } from "@trigger.dev/core/v3";
1013
import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys";
1114
import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
@@ -109,6 +112,18 @@ export type ApiKeyEnvironmentResolution =
109112
* scopes explicitly grant full access; restricted keys fail closed here
110113
* (`reason: "restricted"`, so callers can explain the rejection).
111114
*/
115+
// A just-created branch env can be missing from the replica when its first deploy authenticates.
116+
function findBranchChildWithReplicaRetry(parentEnvironmentId: string, branchName: string) {
117+
const where = { parentEnvironmentId, branchName, archivedAt: null };
118+
return findWithReplicaRetry({
119+
replicaFind: () => $replica.runtimeEnvironment.findFirst({ where }),
120+
primaryFind: () => prisma.runtimeEnvironment.findFirst({ where }),
121+
hasDedicatedReplica: isReadReplicaClient($replica),
122+
retryDelayMs: { min: 50, max: 200 },
123+
onOutcome: observeBranchEnvironmentReplicaMiss,
124+
});
125+
}
126+
112127
async function resolveEnvironmentByApiKey(
113128
apiKey: string,
114129
branchName: string | undefined,
@@ -227,7 +242,9 @@ async function resolveEnvironmentByApiKey(
227242
return { ok: false, reason: "not-found" };
228243
}
229244

230-
const childEnvironment = environment.childEnvironments.at(0);
245+
const childEnvironment =
246+
environment.childEnvironments.at(0) ??
247+
(await findBranchChildWithReplicaRetry(environment.id, branch));
231248

232249
if (childEnvironment) {
233250
return {
@@ -248,7 +265,9 @@ async function resolveEnvironmentByApiKey(
248265

249266
// If there is a named DEV branch (other than default), return it
250267
if (environment.type === "DEVELOPMENT" && branch !== undefined && !isDefaultDevBranch(branch)) {
251-
const childEnvironment = environment.childEnvironments.at(0);
268+
const childEnvironment =
269+
environment.childEnvironments.at(0) ??
270+
(await findBranchChildWithReplicaRetry(environment.id, branch));
252271

253272
if (childEnvironment) {
254273
return {

apps/webapp/app/services/apiAuth.server.ts

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { json } from "@remix-run/server-runtime";
22
import { SignJWT } from "jose";
33
import { z } from "zod";
44

5-
import { $replica } from "~/db.server";
5+
import { $replica, prisma } from "~/db.server";
66
import { env } from "~/env.server";
77
import { findProjectByRef } from "~/models/project.server";
88
import {
@@ -33,11 +33,15 @@ import {
3333
} from "./organizationAccessToken.server";
3434
import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server";
3535
import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
36+
import type { Prisma } from "@trigger.dev/database";
3637
import {
3738
authenticateAuthorizeBearerWithTelemetry,
3839
authenticateBearerWithTelemetry,
40+
observeBranchEnvironmentReplicaMiss,
3941
observeLegacyBearerAuthentication,
4042
} from "~/services/authTelemetry.server";
43+
import { findWithReplicaRetry } from "~/services/replicaLagRetry.server";
44+
import { isReadReplicaClient } from "@internal/run-store";
4145

4246
const ClaimsSchema = z.object({
4347
scopes: z.array(z.string()).optional(),
@@ -653,6 +657,21 @@ export async function authenticatedEnvironmentForAuthentication(
653657
return environment;
654658
}
655659

660+
const BRANCH_ENV_REPLICA_RETRY_DELAY_MS = { min: 50, max: 200 };
661+
662+
// A just-created branch env can be missing from the replica when its first deploy authenticates.
663+
function findBranchEnvironment(where: Prisma.RuntimeEnvironmentWhereInput) {
664+
return findWithReplicaRetry({
665+
replicaFind: () =>
666+
$replica.runtimeEnvironment.findFirst({ where, include: authIncludeWithParent }),
667+
primaryFind: () =>
668+
prisma.runtimeEnvironment.findFirst({ where, include: authIncludeWithParent }),
669+
hasDedicatedReplica: isReadReplicaClient($replica),
670+
retryDelayMs: BRANCH_ENV_REPLICA_RETRY_DELAY_MS,
671+
onOutcome: observeBranchEnvironmentReplicaMiss,
672+
});
673+
}
674+
656675
async function resolveEnvironmentForAuthentication(
657676
auth: AuthenticationResult,
658677
projectRef: string,
@@ -742,21 +761,18 @@ async function resolveEnvironmentForAuthentication(
742761
return toAuthenticated(environment);
743762
}
744763

745-
const environment = await $replica.runtimeEnvironment.findFirst({
746-
where: {
747-
projectId: project.id,
748-
type: slug === "dev" ? "DEVELOPMENT" : "PREVIEW",
749-
branchName: resolvedBranch,
750-
...(slug === "dev"
751-
? {
752-
orgMember: {
753-
userId: user.id,
754-
},
755-
}
756-
: {}),
757-
archivedAt: null,
758-
},
759-
include: authIncludeWithParent,
764+
const environment = await findBranchEnvironment({
765+
projectId: project.id,
766+
type: slug === "dev" ? "DEVELOPMENT" : "PREVIEW",
767+
branchName: resolvedBranch,
768+
...(slug === "dev"
769+
? {
770+
orgMember: {
771+
userId: user.id,
772+
},
773+
}
774+
: {}),
775+
archivedAt: null,
760776
});
761777

762778
if (!environment) {
@@ -813,15 +829,12 @@ async function resolveEnvironmentForAuthentication(
813829
return toAuthenticated(environment);
814830
}
815831

816-
const environment = await $replica.runtimeEnvironment.findFirst({
817-
where: {
818-
projectId: project.id,
819-
// No Development branches for OAT
820-
type: "PREVIEW",
821-
branchName: resolvedBranch,
822-
archivedAt: null,
823-
},
824-
include: authIncludeWithParent,
832+
const environment = await findBranchEnvironment({
833+
projectId: project.id,
834+
// No Development branches for OAT
835+
type: "PREVIEW",
836+
branchName: resolvedBranch,
837+
archivedAt: null,
825838
});
826839

827840
if (!environment) {

apps/webapp/app/services/authTelemetry.server.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
import { authFeatureControls } from "~/services/authFeatureControls.server";
1212
import { rbac } from "~/services/rbac.server";
1313
import { singleton } from "~/utils/singleton";
14+
import type { ReplicaRetryOutcome } from "~/services/replicaLagRetry.server";
1415

1516
type ApiAuthResult = "success" | "invalid" | "forbidden" | "disabled" | "error";
1617

@@ -23,6 +24,10 @@ const telemetry = singleton("apiAuthTelemetry", () => {
2324
description: "Environment bearer authentication duration",
2425
unit: "ms",
2526
});
27+
const branchReplicaMiss = meter.createCounter("api_auth.branch_env_replica_miss", {
28+
description:
29+
"Branch environment lookups that missed the read replica, by recovery outcome (or not_found)",
30+
});
2631

2732
meter
2833
.createObservableGauge("api_auth.rollout_mode", {
@@ -35,9 +40,13 @@ const telemetry = singleton("apiAuthTelemetry", () => {
3540
});
3641
});
3742

38-
return { attempts, duration };
43+
return { attempts, duration, branchReplicaMiss };
3944
});
4045

46+
export function observeBranchEnvironmentReplicaMiss(outcome: ReplicaRetryOutcome) {
47+
telemetry.branchReplicaMiss.add(1, { outcome });
48+
}
49+
4150
export async function authenticateBearerWithTelemetry(
4251
request: Request,
4352
options: BearerAuthOptions
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { setTimeout as sleep } from "node:timers/promises";
2+
3+
export type ReplicaRetryOutcome = "replica_retry" | "primary" | "not_found";
4+
5+
// Replica-lag guard: on a miss, retry the replica once with jitter, then let the primary decide.
6+
export async function findWithReplicaRetry<T>({
7+
replicaFind,
8+
primaryFind,
9+
hasDedicatedReplica,
10+
retryDelayMs,
11+
onOutcome,
12+
}: {
13+
replicaFind: () => Promise<T | null>;
14+
primaryFind: () => Promise<T | null>;
15+
hasDedicatedReplica: boolean;
16+
retryDelayMs: { min: number; max: number };
17+
onOutcome?: (outcome: ReplicaRetryOutcome) => void;
18+
}): Promise<T | null> {
19+
const report = (outcome: ReplicaRetryOutcome) => {
20+
try {
21+
onOutcome?.(outcome);
22+
} catch {}
23+
};
24+
25+
const found = await replicaFind();
26+
if (found) {
27+
return found;
28+
}
29+
30+
// Without a dedicated replica both lookups hit the same database, so a retry can't help.
31+
if (!hasDedicatedReplica) {
32+
report("not_found");
33+
return null;
34+
}
35+
36+
await sleep(retryDelayMs.min + Math.random() * Math.max(0, retryDelayMs.max - retryDelayMs.min));
37+
38+
const retried = await replicaFind();
39+
if (retried) {
40+
report("replica_retry");
41+
return retried;
42+
}
43+
44+
const fromPrimary = await primaryFind();
45+
report(fromPrimary ? "primary" : "not_found");
46+
return fromPrimary;
47+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { findWithReplicaRetry } from "~/services/replicaLagRetry.server";
3+
4+
const base = { hasDedicatedReplica: true, retryDelayMs: { min: 0, max: 0 } };
5+
6+
describe("findWithReplicaRetry", () => {
7+
it("returns the first replica hit without retrying or touching the primary", async () => {
8+
const replicaFind = vi.fn().mockResolvedValue({ id: "env_1" });
9+
const primaryFind = vi.fn();
10+
const onOutcome = vi.fn();
11+
12+
const result = await findWithReplicaRetry({ ...base, replicaFind, primaryFind, onOutcome });
13+
14+
expect(result).toEqual({ id: "env_1" });
15+
expect(replicaFind).toHaveBeenCalledTimes(1);
16+
expect(primaryFind).not.toHaveBeenCalled();
17+
expect(onOutcome).not.toHaveBeenCalled();
18+
});
19+
20+
it("recovers via a replica retry when the row appears on the second read", async () => {
21+
const replicaFind = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({ id: "env_1" });
22+
const primaryFind = vi.fn();
23+
const onOutcome = vi.fn();
24+
25+
const result = await findWithReplicaRetry({ ...base, replicaFind, primaryFind, onOutcome });
26+
27+
expect(result).toEqual({ id: "env_1" });
28+
expect(replicaFind).toHaveBeenCalledTimes(2);
29+
expect(primaryFind).not.toHaveBeenCalled();
30+
expect(onOutcome).toHaveBeenCalledWith("replica_retry");
31+
});
32+
33+
it("falls back to the primary when the replica misses twice", async () => {
34+
const replicaFind = vi.fn().mockResolvedValue(null);
35+
const primaryFind = vi.fn().mockResolvedValue({ id: "env_1" });
36+
const onOutcome = vi.fn();
37+
38+
const result = await findWithReplicaRetry({ ...base, replicaFind, primaryFind, onOutcome });
39+
40+
expect(result).toEqual({ id: "env_1" });
41+
expect(replicaFind).toHaveBeenCalledTimes(2);
42+
expect(primaryFind).toHaveBeenCalledTimes(1);
43+
expect(onOutcome).toHaveBeenCalledWith("primary");
44+
});
45+
46+
it("reports a genuine miss and returns null", async () => {
47+
const replicaFind = vi.fn().mockResolvedValue(null);
48+
const primaryFind = vi.fn().mockResolvedValue(null);
49+
const onOutcome = vi.fn();
50+
51+
const result = await findWithReplicaRetry({ ...base, replicaFind, primaryFind, onOutcome });
52+
53+
expect(result).toBeNull();
54+
expect(onOutcome).toHaveBeenCalledWith("not_found");
55+
expect(onOutcome).toHaveBeenCalledTimes(1);
56+
});
57+
58+
it("does a single lookup when there is no dedicated replica", async () => {
59+
const replicaFind = vi.fn().mockResolvedValue(null);
60+
const primaryFind = vi.fn();
61+
const onOutcome = vi.fn();
62+
63+
const result = await findWithReplicaRetry({
64+
...base,
65+
hasDedicatedReplica: false,
66+
replicaFind,
67+
primaryFind,
68+
onOutcome,
69+
});
70+
71+
expect(result).toBeNull();
72+
expect(replicaFind).toHaveBeenCalledTimes(1);
73+
expect(primaryFind).not.toHaveBeenCalled();
74+
expect(onOutcome).toHaveBeenCalledWith("not_found");
75+
});
76+
77+
it("does not fail the lookup when the outcome callback throws", async () => {
78+
const replicaFind = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({ id: "env_1" });
79+
const primaryFind = vi.fn();
80+
const onOutcome = vi.fn(() => {
81+
throw new Error("meter unavailable");
82+
});
83+
84+
const result = await findWithReplicaRetry({ ...base, replicaFind, primaryFind, onOutcome });
85+
86+
expect(result).toEqual({ id: "env_1" });
87+
});
88+
});

0 commit comments

Comments
 (0)