@@ -31,34 +31,51 @@ const BUDGET_MARKER_MAX_AGE_MS = 24 * 60 * 60 * 1000;
3131export const MAX_PREVIEW_POLL_ATTEMPTS = 5 ;
3232
3333type 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
3545async 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. */
6077export 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
7087export 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 }
0 commit comments