Skip to content

Commit c99e7bd

Browse files
committed
fix(database,webapp): make the retry kill switch a true revert, blank-tolerant transaction env vars, and drop test mocks
- Gate the acquisition-error exclusion from the maxRetries branch on startRetry actually being active, so disabling the new retry falls back to prior maxRetries behavior for the callers that set it (e.g. dashboardPreferences). - Generic DATABASE_TRANSACTION_* numeric vars fall back to their default on a blank value instead of coercing to 0. - Rewrite transaction.test.ts to use plain counters/closures instead of vi.fn() spies.
1 parent 0e117c2 commit c99e7bd

3 files changed

Lines changed: 145 additions & 54 deletions

File tree

apps/webapp/app/env.server.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,13 @@ const OptionalBoolEnv = z.preprocess((v) => {
120120
return ["true", "1"].includes(v.toLowerCase().trim());
121121
}, z.boolean().optional());
122122

123+
/** Int env var with a default where a blank/whitespace value falls back to the default instead of coercing to 0. */
124+
const IntEnvWithDefault = (defaultValue: number) =>
125+
z.preprocess(
126+
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
127+
z.coerce.number().int().default(defaultValue)
128+
);
129+
123130
/**
124131
* Optional int env var for a limit that can be switched off. Blank, whitespace and `0` all mean
125132
* "no limit" and normalise to undefined; anything else that is set must be greater than zero.
@@ -148,13 +155,13 @@ const EnvironmentSchema = z
148155
DATABASE_WRITER_CONNECTION_TIMEOUT: OptionalIntEnv,
149156
DATABASE_READ_REPLICA_POOL_TIMEOUT: OptionalIntEnv,
150157
DATABASE_READ_REPLICA_CONNECTION_TIMEOUT: OptionalIntEnv,
151-
DATABASE_TRANSACTION_MAX_WAIT_MS: z.coerce.number().int().default(10000),
158+
DATABASE_TRANSACTION_MAX_WAIT_MS: IntEnvWithDefault(10000),
152159
DATABASE_TRANSACTION_START_RETRY_ENABLED: BoolEnv.default(true),
153-
DATABASE_TRANSACTION_START_RETRY_MAX_ATTEMPTS: z.coerce.number().int().default(2),
154-
DATABASE_TRANSACTION_START_RETRY_BACKOFF_MIN_MS: z.coerce.number().int().default(50),
155-
DATABASE_TRANSACTION_START_RETRY_BACKOFF_MAX_MS: z.coerce.number().int().default(250),
156-
DATABASE_TRANSACTION_START_RETRY_BUDGET_PER_SEC: z.coerce.number().int().default(50),
157-
DATABASE_TRANSACTION_START_RETRY_BUDGET_BURST: z.coerce.number().int().default(100),
160+
DATABASE_TRANSACTION_START_RETRY_MAX_ATTEMPTS: IntEnvWithDefault(2),
161+
DATABASE_TRANSACTION_START_RETRY_BACKOFF_MIN_MS: IntEnvWithDefault(50),
162+
DATABASE_TRANSACTION_START_RETRY_BACKOFF_MAX_MS: IntEnvWithDefault(250),
163+
DATABASE_TRANSACTION_START_RETRY_BUDGET_PER_SEC: IntEnvWithDefault(50),
164+
DATABASE_TRANSACTION_START_RETRY_BUDGET_BURST: IntEnvWithDefault(100),
158165
RUN_OPS_DATABASE_TRANSACTION_MAX_WAIT_MS: OptionalIntEnv,
159166
RUN_OPS_DATABASE_TRANSACTION_START_RETRY_ENABLED: OptionalBoolEnv,
160167
RUN_OPS_DATABASE_TRANSACTION_START_RETRY_MAX_ATTEMPTS: OptionalIntEnv,

internal-packages/database/src/transaction.test.ts

Lines changed: 129 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, vi } from "vitest";
1+
import { describe, expect, it } from "vitest";
22
import {
33
$transaction,
44
isTransactionAcquisitionError,
@@ -41,6 +41,18 @@ function config(
4141
};
4242
}
4343

44+
function counter() {
45+
let calls = 0;
46+
return {
47+
get calls() {
48+
return calls;
49+
},
50+
tick() {
51+
calls += 1;
52+
},
53+
};
54+
}
55+
4456
describe("isTransactionAcquisitionError", () => {
4557
it("is true only for P2028 raised at acquisition", () => {
4658
expect(isTransactionAcquisitionError(acquisitionError())).toBe(true);
@@ -62,53 +74,89 @@ describe("isTransactionAcquisitionError", () => {
6274

6375
describe("withTransactionStartRetry", () => {
6476
it("runs once on success", async () => {
65-
const run = vi.fn().mockResolvedValue("ok");
77+
const c = counter();
78+
const run = async () => {
79+
c.tick();
80+
return "ok";
81+
};
6682
await expect(withTransactionStartRetry(run, config())).resolves.toBe("ok");
67-
expect(run).toHaveBeenCalledTimes(1);
83+
expect(c.calls).toBe(1);
6884
});
6985

7086
it("retries an acquisition failure then succeeds", async () => {
71-
const run = vi.fn().mockRejectedValueOnce(acquisitionError()).mockResolvedValueOnce("ok");
87+
const c = counter();
88+
const run = async () => {
89+
c.tick();
90+
if (c.calls === 1) throw acquisitionError();
91+
return "ok";
92+
};
7293
await expect(withTransactionStartRetry(run, config())).resolves.toBe("ok");
73-
expect(run).toHaveBeenCalledTimes(2);
94+
expect(c.calls).toBe(2);
7495
});
7596

7697
it("does NOT retry P2024", async () => {
98+
const c = counter();
7799
const err = poolTimeoutError();
78-
const run = vi.fn().mockRejectedValue(err);
100+
const run = async () => {
101+
c.tick();
102+
throw err;
103+
};
79104
await expect(withTransactionStartRetry(run, config())).rejects.toBe(err);
80-
expect(run).toHaveBeenCalledTimes(1);
105+
expect(c.calls).toBe(1);
81106
});
82107

83108
it("stops after maxAttempts total attempts", async () => {
84-
const run = vi.fn().mockRejectedValue(acquisitionError());
109+
const c = counter();
110+
const run = async () => {
111+
c.tick();
112+
throw acquisitionError();
113+
};
85114
await expect(withTransactionStartRetry(run, config({ maxAttempts: 3 }))).rejects.toMatchObject({
86115
code: "P2028",
87116
});
88-
expect(run).toHaveBeenCalledTimes(3);
117+
expect(c.calls).toBe(3);
89118
});
90119

91120
it("runs once when disabled", async () => {
92-
const run = vi.fn().mockRejectedValue(acquisitionError());
121+
const c = counter();
122+
const run = async () => {
123+
c.tick();
124+
throw acquisitionError();
125+
};
93126
await expect(withTransactionStartRetry(run, config({ enabled: false }))).rejects.toMatchObject({
94127
code: "P2028",
95128
});
96-
expect(run).toHaveBeenCalledTimes(1);
129+
expect(c.calls).toBe(1);
97130
});
98131

99132
it("does not retry when the budget is exhausted", async () => {
100-
const run = vi.fn().mockRejectedValue(acquisitionError());
101-
const budget = { tryConsume: vi.fn().mockReturnValue(false) };
133+
const c = counter();
134+
const budgetChecks = counter();
135+
const run = async () => {
136+
c.tick();
137+
throw acquisitionError();
138+
};
139+
const budget = {
140+
tryConsume() {
141+
budgetChecks.tick();
142+
return false;
143+
},
144+
};
102145
await expect(
103146
withTransactionStartRetry(run, { ...config({ maxAttempts: 5 }), budget })
104147
).rejects.toMatchObject({ code: "P2028" });
105-
expect(run).toHaveBeenCalledTimes(1);
106-
expect(budget.tryConsume).toHaveBeenCalledTimes(1);
148+
expect(c.calls).toBe(1);
149+
expect(budgetChecks.calls).toBe(1);
107150
});
108151

109152
it("sleeps a jittered delay within [min, max]", async () => {
153+
const c = counter();
110154
const delays: number[] = [];
111-
const run = vi.fn().mockRejectedValueOnce(acquisitionError()).mockResolvedValueOnce("ok");
155+
const run = async () => {
156+
c.tick();
157+
if (c.calls === 1) throw acquisitionError();
158+
return "ok";
159+
};
112160
await withTransactionStartRetry(run, {
113161
options: { enabled: true, maxAttempts: 2, backoffMinMs: 50, backoffMaxMs: 250 },
114162
sleep: (ms) => {
@@ -140,77 +188,111 @@ describe("TokenBucketRetryBudget", () => {
140188
});
141189

142190
describe("$transaction startRetry wiring", () => {
143-
it("retries a transaction start that fails with an acquisition error", async () => {
144-
let calls = 0;
145-
const prisma = {
146-
$transaction: vi.fn((fn: (tx: unknown) => Promise<unknown>) => {
147-
calls += 1;
148-
if (calls === 1) return Promise.reject(acquisitionError());
149-
return fn({});
150-
}),
151-
} as any;
191+
function fakeClient(behavior: (call: number) => Promise<unknown>) {
192+
const c = counter();
193+
return {
194+
client: {
195+
$transaction: (fn: (tx: unknown) => Promise<unknown>, _options?: unknown) => {
196+
c.tick();
197+
return behavior(c.calls).then(() => fn({}));
198+
},
199+
} as any,
200+
get calls() {
201+
return c.calls;
202+
},
203+
};
204+
}
152205

206+
it("retries a transaction start that fails with an acquisition error", async () => {
207+
const fake = fakeClient((call) =>
208+
call === 1 ? Promise.reject(acquisitionError()) : Promise.resolve()
209+
);
153210
const result = await $transaction(
154-
prisma,
211+
fake.client,
155212
async () => "done",
156213
() => {},
157214
{ startRetry: config() }
158215
);
159-
160216
expect(result).toBe("done");
161-
expect(prisma.$transaction).toHaveBeenCalledTimes(2);
217+
expect(fake.calls).toBe(2);
162218
});
163219

164220
it("does not retry without a startRetry config", async () => {
165221
const err = acquisitionError();
166-
const prisma = { $transaction: vi.fn().mockRejectedValue(err) } as any;
167-
const onError = vi.fn();
168-
await expect($transaction(prisma, async () => "x", onError, {})).rejects.toBe(err);
169-
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
170-
expect(onError).toHaveBeenCalledWith(err);
222+
const fake = fakeClient(() => Promise.reject(err));
223+
let captured: unknown;
224+
await expect(
225+
$transaction(
226+
fake.client,
227+
async () => "x",
228+
(e) => {
229+
captured = e;
230+
},
231+
{}
232+
)
233+
).rejects.toBe(err);
234+
expect(fake.calls).toBe(1);
235+
expect(captured).toBe(err);
171236
});
172237

173238
it("passes maxWait through to prisma.$transaction options", async () => {
174-
const prisma = {
175-
$transaction: vi.fn((fn: (tx: unknown) => Promise<unknown>) => fn({})),
239+
let seenOptions: unknown;
240+
const client = {
241+
$transaction: (fn: (tx: unknown) => Promise<unknown>, options?: unknown) => {
242+
seenOptions = options;
243+
return fn({});
244+
},
176245
} as any;
177246
await $transaction(
178-
prisma,
247+
client,
179248
async () => "x",
180249
() => {},
181250
{ maxWait: 10000 }
182251
);
183-
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { maxWait: 10000 });
252+
expect(seenOptions).toEqual({ maxWait: 10000 });
184253
});
185254

186-
it("UNLIMITED_RETRY_BUDGET always consumes", () => {
187-
expect(UNLIMITED_RETRY_BUDGET.tryConsume()).toBe(true);
255+
it("does not let maxRetries retry an acquisition error while startRetry is active", async () => {
256+
const fake = fakeClient(() => Promise.reject(acquisitionError()));
257+
await expect(
258+
$transaction(
259+
fake.client,
260+
async () => "x",
261+
() => {},
262+
{ startRetry: config({ maxAttempts: 2 }), maxRetries: 3 }
263+
)
264+
).rejects.toMatchObject({ code: "P2028" });
265+
expect(fake.calls).toBe(2);
188266
});
189267

190-
it("does not let maxRetries retry an acquisition error beyond the startRetry budget", async () => {
191-
const prisma = { $transaction: vi.fn().mockRejectedValue(acquisitionError()) } as any;
268+
it("falls back to maxRetries for acquisition errors when startRetry is disabled", async () => {
269+
const fake = fakeClient(() => Promise.reject(acquisitionError()));
192270
await expect(
193271
$transaction(
194-
prisma,
272+
fake.client,
195273
async () => "x",
196274
() => {},
197-
{ startRetry: config({ maxAttempts: 2 }), maxRetries: 3 }
275+
{ startRetry: config({ enabled: false }), maxRetries: 3 }
198276
)
199277
).rejects.toMatchObject({ code: "P2028" });
200-
expect(prisma.$transaction).toHaveBeenCalledTimes(2);
278+
expect(fake.calls).toBe(4);
201279
});
202280

203281
it("still lets maxRetries retry a serialization error (P2034)", async () => {
204282
const serializationError = { code: "P2034", message: "write conflict / deadlock" };
205-
const prisma = { $transaction: vi.fn().mockRejectedValue(serializationError) } as any;
283+
const fake = fakeClient(() => Promise.reject(serializationError));
206284
await expect(
207285
$transaction(
208-
prisma,
286+
fake.client,
209287
async () => "x",
210288
() => {},
211289
{ maxRetries: 2 }
212290
)
213291
).rejects.toMatchObject({ code: "P2034" });
214-
expect(prisma.$transaction).toHaveBeenCalledTimes(3);
292+
expect(fake.calls).toBe(3);
293+
});
294+
295+
it("UNLIMITED_RETRY_BUDGET always consumes", () => {
296+
expect(UNLIMITED_RETRY_BUDGET.tryConsume()).toBe(true);
215297
});
216298
});

internal-packages/database/src/transaction.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,8 @@ export async function $transaction<R>(
230230
}
231231

232232
const startRetry = attempt === 0 ? options?.startRetry : undefined;
233+
const startRetryActive =
234+
!!startRetry && startRetry.options.enabled && startRetry.options.maxAttempts > 1;
233235

234236
try {
235237
return await withTransactionStartRetry(
@@ -239,7 +241,7 @@ export async function $transaction<R>(
239241
} catch (error) {
240242
if (
241243
isPrismaRetriableError(error) &&
242-
!isTransactionAcquisitionError(error) &&
244+
!(startRetryActive && isTransactionAcquisitionError(error)) &&
243245
typeof options?.maxRetries === "number" &&
244246
attempt < options.maxRetries
245247
) {

0 commit comments

Comments
 (0)