Skip to content

Commit 43efc69

Browse files
authored
fix(review): make preview-poll-budget increment safe under concurrent triggers (#7833)
recordPreviewPollAttempt did an unguarded read-modify-write against R2: two triggers racing for the same head SHA could both read count=N and both write count=N+1, silently losing one increment and letting the actual poll count exceed MAX_PREVIEW_POLL_ATTEMPTS. Switch the write to a compare-and-swap against the marker's httpEtag (etagMatches for an existing object, etagDoesNotMatch:"*" for the first write), retrying a bounded number of times on a precondition miss so a racing writer's newer count is re-read and both increments land. Preserves the existing fail-open-on-write-failure contract: exhausting the retries degrades to "this attempt didn't count", the same safe direction the module already documents.
1 parent 17ae3e6 commit 43efc69

2 files changed

Lines changed: 116 additions & 23 deletions

File tree

src/review/visual/preview-poll-budget.ts

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,34 +31,51 @@ const BUDGET_MARKER_MAX_AGE_MS = 24 * 60 * 60 * 1000;
3131
export const MAX_PREVIEW_POLL_ATTEMPTS = 5;
3232

3333
type BudgetMarker = { count: number; firstAttemptAt: number };
34+
// A read of the marker plus the R2 httpEtag it was stored under (null when no object exists yet). The etag is
35+
// what recordPreviewPollAttempt's conditional write compares-and-swaps against so two triggers racing for the
36+
// same head SHA can't both read count=N and both write count=N+1, silently losing one increment (#7780).
37+
type BudgetRead = { marker: BudgetMarker | null; etag: string | null };
38+
// How many times recordPreviewPollAttempt re-reads + retries its conditional write when another trigger wins
39+
// the compare-and-swap first. Small: the race window is a single R2 round-trip and realistically at most a
40+
// handful of triggers ever contend for one head SHA at once, so a couple of retries converges; exhausting them
41+
// just degrades to the pre-#7780 best-effort "this attempt didn't count" outcome, the same safe direction the
42+
// module already accepts for a genuine write failure.
43+
const BUDGET_CAS_MAX_ATTEMPTS = 3;
3444

3545
async function budgetR2Key(headSha: string): Promise<string> {
3646
const fingerprint = await sha256Hex(`${headSha}:preview-poll-budget`);
3747
return `${BUDGET_R2_NAMESPACE}${fingerprint.slice(0, 40)}.json`;
3848
}
3949

40-
/** Shared read path for both public functions below. Returns null (fail-open toward "no attempts yet") on
41-
* any read error, a malformed marker, or one older than BUDGET_MARKER_MAX_AGE_MS -- a stale marker is
42-
* treated as absent, not as "budget still exhausted from a previous, unrelated review cycle". */
43-
async function readBudgetMarker(env: Env, headSha: string): Promise<BudgetMarker | null> {
44-
if (!env.REVIEW_AUDIT) return null;
50+
/** Validate a raw stored payload into a BudgetMarker, or null when it's malformed or older than
51+
* BUDGET_MARKER_MAX_AGE_MS -- a stale marker is treated as absent, not as "budget still exhausted from a
52+
* previous, unrelated review cycle". */
53+
function parseBudgetMarker(text: string): BudgetMarker | null {
54+
const marker = JSON.parse(text) as Partial<BudgetMarker>;
55+
if (typeof marker.count !== "number" || typeof marker.firstAttemptAt !== "number") return null;
56+
if (Date.now() - marker.firstAttemptAt >= BUDGET_MARKER_MAX_AGE_MS) return null;
57+
return { count: marker.count, firstAttemptAt: marker.firstAttemptAt };
58+
}
59+
60+
/** Shared read path for both public functions below. Returns a fail-open read (marker null, etag null) on any
61+
* read error or a malformed/stale marker. Also surfaces the object's httpEtag so the increment path can do a
62+
* compare-and-swap write against exactly the version it read (#7780). */
63+
async function readBudgetMarker(env: Env, headSha: string): Promise<BudgetRead> {
64+
if (!env.REVIEW_AUDIT) return { marker: null, etag: null };
4565
try {
4666
const object = await env.REVIEW_AUDIT.get(await budgetR2Key(headSha));
47-
if (!object) return null;
48-
const marker = JSON.parse(await new Response(object.body).text()) as Partial<BudgetMarker>;
49-
if (typeof marker.count !== "number" || typeof marker.firstAttemptAt !== "number") return null;
50-
if (Date.now() - marker.firstAttemptAt >= BUDGET_MARKER_MAX_AGE_MS) return null;
51-
return { count: marker.count, firstAttemptAt: marker.firstAttemptAt };
67+
if (!object) return { marker: null, etag: null };
68+
return { marker: parseBudgetMarker(await new Response(object.body).text()), etag: object.httpEtag };
5269
} catch {
53-
return null;
70+
return { marker: null, etag: null };
5471
}
5572
}
5673

5774
/** How many preview-poll attempts have already been recorded for `headSha` -- 0 when no marker exists,
5875
* storage is unavailable, or the existing marker has expired. Consulted by buildCapture BEFORE treating a
5976
* "still building" preview state as worth another attempt. */
6077
export async function previewPollAttemptCount(env: Env, headSha: string): Promise<number> {
61-
return (await readBudgetMarker(env, headSha))?.count ?? 0;
78+
return (await readBudgetMarker(env, headSha)).marker?.count ?? 0;
6279
}
6380

6481
/** Record one more preview-poll attempt for `headSha`, preserving the marker's original `firstAttemptAt`
@@ -70,9 +87,20 @@ export async function previewPollAttemptCount(env: Env, headSha: string): Promis
7087
export async function recordPreviewPollAttempt(env: Env, headSha: string): Promise<void> {
7188
if (!env.REVIEW_AUDIT) return;
7289
try {
73-
const existing = await readBudgetMarker(env, headSha);
74-
const marker: BudgetMarker = { count: (existing?.count ?? 0) + 1, firstAttemptAt: existing?.firstAttemptAt ?? Date.now() };
75-
await env.REVIEW_AUDIT.put(await budgetR2Key(headSha), JSON.stringify(marker), { httpMetadata: { contentType: "application/json" } });
90+
const key = await budgetR2Key(headSha);
91+
for (let attempt = 0; attempt < BUDGET_CAS_MAX_ATTEMPTS; attempt += 1) {
92+
const existing = await readBudgetMarker(env, headSha);
93+
const marker: BudgetMarker = { count: (existing.marker?.count ?? 0) + 1, firstAttemptAt: existing.marker?.firstAttemptAt ?? Date.now() };
94+
// Compare-and-swap against exactly the version we just read: only overwrite the existing object if its
95+
// etag is unchanged (etagMatches), or -- when we read no object -- only create one if none exists yet
96+
// (etagDoesNotMatch: "*"). If another trigger wrote in between, R2 returns null instead of writing, and
97+
// we loop to re-read its newer count and retry, so no increment is lost (#7780).
98+
const onlyIf: R2Conditional = existing.etag !== null ? { etagMatches: existing.etag } : { etagDoesNotMatch: "*" };
99+
const written = await env.REVIEW_AUDIT.put(key, JSON.stringify(marker), { httpMetadata: { contentType: "application/json" }, onlyIf });
100+
if (written) return;
101+
}
102+
// Exhausted retries under sustained contention -- degrade to "this attempt didn't count", the same safe
103+
// failure direction the module already accepts for a genuine write failure (see doc comment above).
76104
} catch {
77105
// best effort -- see doc comment above
78106
}

test/unit/preview-poll-budget.test.ts

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,38 @@ import { createTestEnv } from "../helpers/d1";
44

55
const HEAD_SHA = "budget-head-sha-1234567890";
66

7-
function memoryBudgetStore(options: { failGet?: boolean; failPut?: boolean; forcedValue?: string } = {}): R2Bucket {
8-
const store = new Map<string, string>();
7+
// An etag-aware in-memory R2 stand-in: each key holds its value plus a monotonic etag, `get` surfaces the
8+
// current httpEtag, and `put` honors the compare-and-swap conditions recordPreviewPollAttempt relies on
9+
// (#7780) -- `etagMatches` writes only when the etag is unchanged, `etagDoesNotMatch: "*"` writes only when
10+
// the object is still absent -- returning null (no write) on a precondition miss exactly as real R2 does.
11+
// `onPut` is an optional hook fired at the START of every put, used to interleave two concurrent writers.
12+
function memoryBudgetStore(
13+
options: { failGet?: boolean; failPut?: boolean; forcedValue?: string; onPut?: (key: string) => Promise<void> | void } = {},
14+
): R2Bucket {
15+
const store = new Map<string, { value: string; etag: string }>();
16+
let etagSeq = 0;
917
return {
1018
async get(key: string) {
1119
if (options.failGet) throw new Error("simulated budget-marker read failure");
1220
// forcedValue bypasses the real per-key store entirely -- used to simulate a corrupted/malformed stored
1321
// marker without needing to know the module's own private R2-key derivation.
14-
if (options.forcedValue !== undefined) return { body: new Response(options.forcedValue).body } as unknown as R2ObjectBody;
15-
const value = store.get(key);
16-
return value === undefined ? null : ({ body: new Response(value).body } as unknown as R2ObjectBody);
22+
if (options.forcedValue !== undefined) return { body: new Response(options.forcedValue).body, httpEtag: "forced-etag" } as unknown as R2ObjectBody;
23+
const entry = store.get(key);
24+
return entry === undefined ? null : ({ body: new Response(entry.value).body, httpEtag: entry.etag } as unknown as R2ObjectBody);
1725
},
18-
async put(key: string, value: unknown) {
26+
async put(key: string, value: unknown, putOptions?: R2PutOptions) {
27+
if (options.onPut) await options.onPut(key);
1928
if (options.failPut) throw new Error("simulated budget-marker write failure");
20-
store.set(key, await new Response(value as BodyInit).text());
21-
return { key } as unknown as R2Object;
29+
const onlyIf = putOptions?.onlyIf as R2Conditional | undefined;
30+
const current = store.get(key);
31+
// Enforce the two compare-and-swap preconditions the production code sends; a miss returns null (real R2
32+
// signals "not written" by returning null rather than throwing).
33+
if (onlyIf?.etagMatches !== undefined && current?.etag !== onlyIf.etagMatches) return null;
34+
if (onlyIf?.etagDoesNotMatch === "*" && current !== undefined) return null;
35+
etagSeq += 1;
36+
const etag = `etag-${etagSeq}`;
37+
store.set(key, { value: await new Response(value as BodyInit).text(), etag });
38+
return { key, etag } as unknown as R2Object;
2239
},
2340
} as unknown as R2Bucket;
2441
}
@@ -118,4 +135,52 @@ describe("previewPollAttemptCount / recordPreviewPollAttempt (#6323 -- durable p
118135
const env = createTestEnv({ REVIEW_AUDIT: memoryBudgetStore({ failGet: true }) });
119136
await expect(recordPreviewPollAttempt(env, HEAD_SHA)).resolves.toBeUndefined();
120137
});
138+
139+
it("does NOT lose an increment when two triggers race for the same head SHA -- both are counted (#7780)", async () => {
140+
// Interleave two concurrent recordPreviewPollAttempt calls so BOTH read the marker before EITHER writes --
141+
// the classic read-modify-write TOCTOU. The first put to reach the store is stalled at a barrier until the
142+
// second writer has read (and is about to write); whichever writes second must have re-read the newer
143+
// count via the compare-and-swap retry, so the final count reflects BOTH increments, not one.
144+
let releaseFirstPut: () => void = () => {};
145+
const firstPutStalled = new Promise<void>((resolve) => {
146+
releaseFirstPut = resolve;
147+
});
148+
let putCalls = 0;
149+
const onPut = async () => {
150+
putCalls += 1;
151+
// Only the very first put blocks; every later put (the retry, and the second writer) runs immediately.
152+
if (putCalls === 1) await firstPutStalled;
153+
};
154+
const env = createTestEnv({ REVIEW_AUDIT: memoryBudgetStore({ onPut }) });
155+
156+
const first = recordPreviewPollAttempt(env, HEAD_SHA); // reads count=0, stalls at its put barrier
157+
// Let the first writer reach (and block at) its put before the second even starts, guaranteeing both read
158+
// count=0 against the same (absent) etag.
159+
await new Promise((r) => setTimeout(r, 0));
160+
const second = recordPreviewPollAttempt(env, HEAD_SHA); // reads count=0 too, writes count=1 (wins the CAS)
161+
await second;
162+
releaseFirstPut(); // the first writer's stalled put now runs; its etagDoesNotMatch:"*" precondition misses
163+
await first; // ...so it retries, re-reads count=1, and writes count=2
164+
165+
await expect(previewPollAttemptCount(env, HEAD_SHA)).resolves.toBe(2);
166+
});
167+
168+
it("gives up after the bounded CAS retries under sustained contention, without throwing (#7780)", async () => {
169+
// A pathological store whose conditional put NEVER succeeds (every etagDoesNotMatch precondition is treated
170+
// as a miss): recordPreviewPollAttempt must exhaust its bounded retries and degrade to "this attempt didn't
171+
// count" -- the same safe failure direction as a genuine write failure -- rather than throw or loop forever.
172+
let putAttempts = 0;
173+
const store = {
174+
async get() {
175+
return null; // always "no marker yet" -> the write path always uses etagDoesNotMatch:"*"
176+
},
177+
async put() {
178+
putAttempts += 1;
179+
return null; // precondition perpetually "misses" -> forces the retry loop to run to exhaustion
180+
},
181+
} as unknown as R2Bucket;
182+
const env = createTestEnv({ REVIEW_AUDIT: store });
183+
await expect(recordPreviewPollAttempt(env, HEAD_SHA)).resolves.toBeUndefined();
184+
expect(putAttempts).toBe(3); // BUDGET_CAS_MAX_ATTEMPTS
185+
});
121186
});

0 commit comments

Comments
 (0)