Skip to content

Commit bb21354

Browse files
authored
fix(locks): renew a held lock while its work runs, so a slow pass cannot be claimed out from under (#9467) (#9507)
* fix(locks): renew a held lock while its work runs, so a slow pass cannot be claimed out from under (#9467) The transient-lock TTLs were sized when the actuation lock covered a short plan-and-execute section. #9013 moved the claim to BEFORE maybePublishPrPublicSurface, so the 600s actuation lock now spans the whole publish -> AI review -> maintain unit -- and the AI review alone can exceed it (3 attempts x up to 600s per attempt per model, with an operator override clamped at 30 minutes PER ATTEMPT). When the TTL lapsed mid-work the holder was never told: a second worker claimed the same PR and both actuated it, producing a duplicate close plus a duplicate explanation comment and duplicate decision records, or a losing pass's placeholder republishing over a real verdict -- the exact thrash #9013 existed to eliminate. The AI-review lock has the same shape: its 1800s TTL is exactly one model's max-effort retry budget, so a second reviewer or a per-repo timeout override runs past it and the two passes' ai_review_cache upserts race. Renewal is compare-and-extend, mirroring the compare-and-delete release and for the same reason: a holder whose key already lapsed and was re-claimed must NOT extend the new owner's lock. It learns it lost instead, so a caller can abort before mutating anything. Fails open throughout, matching every other operation in this module: an adapter without renewIfValue, a fail-open claim with no token, or a throwing renewal all leave the lock on its original fixed TTL -- never worse than before, and never a reason to block real work. A transient renewal error is deliberately NOT treated as losing the lock, since that would abort work that is still legitimately holding it. The end-to-end tests drive claim -> heartbeat -> competing claim against a cache that genuinely expires keys, so the invariant asserted is the one that matters -- a competing pass stays refused while the holder works -- and the paired regression shows the same lock lapsing without the heartbeat. * refactor(locks): drop an unreachable pre-await stopped guard in the heartbeat (#9467) stop() clears the interval, so a callback can never START after it -- the guard before the first await was dead. The check that matters is the one after the renewal await, where stop() can genuinely have landed mid-call, and that stays.
1 parent 0822ca3 commit bb21354

7 files changed

Lines changed: 440 additions & 6 deletions

File tree

src/env.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ declare global {
7272
* deleting a different holder's live claim on the same key. Required on any adapter that implements
7373
* `claim()` (validated at self-host boot). */
7474
releaseIfValue?(key: string, value: string): Promise<boolean>;
75+
/** Atomic compare-and-extend: resets `key`'s TTL only when its current value equals `value`, returning
76+
* whether it was extended (#9467). The compare is what makes a renewal safe — a holder whose lock has
77+
* already expired and been re-claimed by someone else must NOT extend the new holder's key, and must be
78+
* able to learn that it lost ownership. Optional: an adapter without it simply gets no renewal, and the
79+
* lock behaves exactly as it did before (fixed TTL). */
80+
renewIfValue?(key: string, value: string, ttlSeconds: number): Promise<boolean>;
7581
};
7682
PUBLIC_API_ORIGIN?: string;
7783
PUBLIC_SITE_ORIGIN?: string;

src/queue/ai-review-orchestration.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
import {
1818
claimTransientLock,
1919
releaseTransientLockIfOwner,
20+
startLockHeartbeat,
21+
type LockHeartbeat,
2022
type TransientLockClaim,
2123
} from "./transient-locks";
2224
import { buildPullRequestAdvisory } from "../rules/advisory";
@@ -134,6 +136,38 @@ export async function claimAiReviewLock(
134136
return claim;
135137
}
136138

139+
/**
140+
* #9467: keep an acquired AI-review lock alive for as long as the review is actually running.
141+
*
142+
* The 1800s TTL is documented as "a crash-safety backstop, not a throughput bound" -- but nothing bounded the
143+
* work below it. At max effort a SINGLE model's retry budget is exactly 3 x 600s = 1800s, so any second
144+
* reviewer, fallback chain, or per-repo claudeTimeoutMs override (clamped at 30 min PER ATTEMPT, #8458) runs
145+
* past it. A second pass then claimed the same key, fired a duplicate LLM call, and the two passes'
146+
* ai_review_cache upserts raced -- last writer wins, so two contradictory verdicts could alternate across
147+
* passes at an unchanged head.
148+
*
149+
* The caller stops it in the same finally that releases the lock. See startLockHeartbeat for the fail-open
150+
* posture: no compare-and-extend support, a fail-open claim, or a throwing renewal all leave the lock on its
151+
* original fixed TTL rather than blocking work.
152+
*/
153+
export function startAiReviewLockHeartbeat(
154+
env: Env,
155+
repoFullName: string,
156+
prNumber: number,
157+
headSha: string,
158+
mode: string,
159+
ownerToken: string | null,
160+
options?: { onLost?: () => void },
161+
): LockHeartbeat {
162+
return startLockHeartbeat(
163+
env,
164+
aiReviewLockKey(repoFullName, prNumber, headSha, mode),
165+
ownerToken,
166+
AI_REVIEW_LOCK_TTL_SECONDS,
167+
options,
168+
);
169+
}
170+
137171
/** Best-effort release, called from a finally block so the lock frees promptly instead of waiting out the TTL. */
138172
export async function releaseAiReviewLock(
139173
env: Env,

src/queue/processors.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,8 @@ import {
363363
// unchanged -- those tests are deeply interspersed with unrelated ones in that file family, not in a cleanly
364364
// extractable describe block, so relocating them is deliberately deferred rather than forced into this PR.
365365
export { claimPrActuationLock, releasePrActuationLock } from "./transient-locks";
366+
import { PR_ACTUATION_LOCK_TTL_SECONDS, prActuationLockKeyForHeartbeat, startLockHeartbeat } from "./transient-locks";
367+
import { startAiReviewLockHeartbeat } from "./ai-review-orchestration";
366368
// #4013 step 2: same shim shape for generateSignalSnapshots -- imported here for processJob's own internal
367369
// call below, and re-exported so src/api/routes.ts and test/unit/queue-trends.test.ts's existing
368370
// `import { generateSignalSnapshots } from "../../src/queue/processors"` keeps working unchanged.
@@ -4236,6 +4238,16 @@ export async function reReviewStoredPullRequest(
42364238
}).catch(() => undefined);
42374239
throw new PrActuationLockContendedError(repoFullName, pr.number, "public-surface-publish");
42384240
}
4241+
// #9467: this lock now spans the WHOLE publish -> AI review -> maintain unit (#9013 moved the claim here),
4242+
// and the AI review alone can outlive the 600s TTL. Renew it while the work runs so a slow-but-healthy pass
4243+
// cannot have its lock claimed out from under it mid-flight. Compare-and-extend, so if this pass has already
4244+
// lost the key it learns that instead of extending the new owner's lock.
4245+
const actuationHeartbeat = startLockHeartbeat(
4246+
env,
4247+
prActuationLockKeyForHeartbeat(repoFullName, pr.number),
4248+
actuationLock.ownerToken,
4249+
PR_ACTUATION_LOCK_TTL_SECONDS,
4250+
);
42394251
let gate: ReturnType<typeof evaluateGateCheck> | undefined;
42404252
try {
42414253
gate = await withReviewPipelineSpan(
@@ -4321,6 +4333,7 @@ export async function reReviewStoredPullRequest(
43214333
);
43224334
});
43234335
} finally {
4336+
actuationHeartbeat.stop();
43244337
await releasePrActuationLock(env, repoFullName, pr.number, actuationLock.ownerToken);
43254338
}
43264339
return true;
@@ -11155,9 +11168,21 @@ async function maybePublishPrPublicSurface(
1115511168
}).catch(() => undefined);
1115611169
aiReview = aiReviewLockContendedResult(advisory);
1115711170
} else {
11171+
// #9467: an LLM call can outlive the 1800s TTL (a single model's max-effort retry budget is exactly
11172+
// 3 x 600s, before a second reviewer or a per-repo timeout override). Renew while the review runs so a
11173+
// slow-but-healthy pass cannot have its lock claimed out from under it and pay for a duplicate call.
11174+
const aiReviewHeartbeat = startAiReviewLockHeartbeat(
11175+
env,
11176+
repoFullName,
11177+
pr.number,
11178+
aiReviewHeadSha,
11179+
settings.aiReviewMode,
11180+
aiReviewLock.ownerToken,
11181+
);
1115811182
try {
1115911183
await aiReviewCacheReadDecideAndRun(aiReviewLock);
1116011184
} finally {
11185+
aiReviewHeartbeat.stop();
1116111186
await releaseAiReviewLock(
1116211187
env,
1116311188
repoFullName,

src/queue/transient-locks.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,74 @@ async function releaseSubmissionLockIfBound(env: Env, key: string, ownerToken: s
136136
return true;
137137
}
138138

139-
const PR_ACTUATION_LOCK_TTL_SECONDS = 600;
139+
/**
140+
* #9467: keep a held lock alive for as long as its owner is actually working, instead of betting that the work
141+
* finishes inside a fixed TTL.
142+
*
143+
* The TTLs were sized when the actuation lock covered only a short plan-and-execute section. #9013 moved the
144+
* claim to BEFORE `maybePublishPrPublicSurface`, so the 600s actuation lock now wraps the entire publish -> AI
145+
* review -> maintain unit -- and the AI review alone can legitimately exceed it (3 attempts x up to 600s per
146+
* attempt, per model, with an operator override clamped at 1,800,000 ms per attempt). When the TTL lapsed
147+
* mid-work the holder was never told: a second worker claimed the same PR and both actuated it, producing a
148+
* duplicate close plus a duplicate explanation comment, or a losing pass's placeholder republished over a real
149+
* verdict -- the exact thrash #9013 existed to eliminate.
150+
*
151+
* The queue proved this shape for its own processing lease in #9023; this is the same idea for transient locks.
152+
* Renewal is compare-and-extend, so a holder that has ALREADY lost the key (TTL lapsed and someone re-claimed,
153+
* or a maintainer stole it via #9008) cannot extend the new owner's lock -- it learns it lost instead, via
154+
* `onLost`, and its caller aborts before mutating anything.
155+
*
156+
* Fails OPEN, exactly like every other operation in this module: an adapter without `renewIfValue`, or a
157+
* throwing renewal, simply leaves the lock on its original fixed TTL -- never worse than the pre-#9467
158+
* behavior, and never a reason to block real work.
159+
*/
160+
export type LockHeartbeat = { stop: () => void };
161+
162+
export function startLockHeartbeat(
163+
env: Env,
164+
key: string,
165+
ownerToken: string | null,
166+
ttlSeconds: number,
167+
options?: { onLost?: () => void; intervalMsOverride?: number },
168+
): LockHeartbeat {
169+
const cache = env.SELFHOST_TRANSIENT_CACHE;
170+
// Nothing to renew: a fail-open claim owns no key, and an adapter without compare-and-extend cannot renew
171+
// safely (a blind re-set would extend whoever holds it now, which is the bug this exists to prevent).
172+
if (!ownerToken || !cache?.renewIfValue) return { stop: () => undefined };
173+
// A third of the TTL gives two chances to recover from a transient renewal failure before the lock lapses.
174+
const intervalMs = options?.intervalMsOverride ?? Math.max(1_000, Math.floor((ttlSeconds * 1000) / 3));
175+
let stopped = false;
176+
const timer = setInterval(() => {
177+
void (async () => {
178+
// No pre-await `stopped` guard: stop() clears the interval, so a callback can never START after it.
179+
// The check that matters is the one AFTER the await below, where stop() CAN have landed mid-renewal.
180+
try {
181+
const stillOurs = await cache.renewIfValue!(key, ownerToken, ttlSeconds);
182+
if (!stillOurs && !stopped) {
183+
stopped = true;
184+
clearInterval(timer);
185+
options?.onLost?.();
186+
}
187+
} catch {
188+
// Fail open: a transient renewal error must not itself abort the work. The next tick retries, and if
189+
// renewals keep failing the lock simply lapses on its original TTL -- the pre-#9467 behavior.
190+
}
191+
})();
192+
}, intervalMs);
193+
// Never hold the process open for a heartbeat (Node only; the Workers runtime has no unref).
194+
(timer as unknown as { unref?: () => void }).unref?.();
195+
return {
196+
stop: () => {
197+
stopped = true;
198+
clearInterval(timer);
199+
},
200+
};
201+
}
202+
203+
export const PR_ACTUATION_LOCK_TTL_SECONDS = 600;
204+
export function prActuationLockKeyForHeartbeat(repoFullName: string, prNumber: number): string {
205+
return prActuationLockKey(repoFullName, prNumber);
206+
}
140207
function prActuationLockKey(repoFullName: string, prNumber: number): string {
141208
return `pr-actuation-lock:${repoFullName.toLowerCase()}#${prNumber}`;
142209
}

src/selfhost/redis-cache.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,20 @@ export function createRedisCache(redis: Redis) {
7171
const result = await redis.set(key, value, "EX", ttlSeconds, "NX");
7272
return result === "OK";
7373
},
74+
// Compare-and-extend (#9467): same atomicity requirement as releaseIfValue below, for the same reason --
75+
// a GET followed by a separate EXPIRE could extend a key that a NEW claimant wrote in between, handing the
76+
// stale holder's renewal to the live holder's lock. Returning false is how a holder discovers it no longer
77+
// owns the key (its TTL lapsed and someone else claimed it, or a maintainer stole it, #9008).
78+
async renewIfValue(key: string, value: string, ttlSeconds: number): Promise<boolean> {
79+
const result = await redis.eval(
80+
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('expire', KEYS[1], ARGV[2]) else return 0 end",
81+
1,
82+
key,
83+
value,
84+
String(ttlSeconds),
85+
);
86+
return result === 1;
87+
},
7488
// Compare-and-delete: the read and the delete must be one atomic server-side step (a Lua eval), or a
7589
// holder's own release could race a NEW claimant's write between a separate GET and DEL and delete the
7690
// wrong holder's key -- the exact race per-holder ownership tokens exist to close.

test/unit/selfhost-redis-cache.test.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,29 +16,39 @@ import { renderMetrics, resetMetrics } from "../../src/selfhost/metrics";
1616
/** Minimal in-memory stand-in for the ioredis methods the cache uses. Emulates real Redis SET NX
1717
* semantics (refuse + return null when NX is requested and the key already exists) so a test
1818
* using this fake actually exercises the atomicity claim() depends on, not just a plain overwrite. */
19-
function fakeRedis(): Redis & { _store: Map<string, string> } {
19+
function fakeRedis(): Redis & { _store: Map<string, string>; _ttls: Map<string, number> } {
2020
const _store = new Map<string, string>();
21+
const _ttls = new Map<string, number>();
2122
return {
2223
_store,
24+
_ttls,
2325
async get(k: string) {
2426
return _store.get(k) ?? null;
2527
},
26-
async set(k: string, v: string, _ex: "EX", _ttl: number, nx?: "NX") {
28+
async set(k: string, v: string, _ex: "EX", ttl: number, nx?: "NX") {
2729
if (nx === "NX" && _store.has(k)) return null;
2830
_store.set(k, v);
31+
_ttls.set(k, ttl);
2932
return "OK";
3033
},
3134
async del(k: string) {
3235
_store.delete(k);
3336
return 1;
3437
},
35-
// Emulates the Lua eval releaseIfValue runs: delete k only when its stored value equals the expected arg.
36-
async eval(_script: string, _numkeys: number, k: string, expected: string) {
38+
// Emulates the two compare-and-act Lua evals this module runs, distinguished by the command they call:
39+
// releaseIfValue deletes on match; renewIfValue (#9467) resets the TTL on match. Both must be no-ops on a
40+
// mismatch -- that is the whole ownership guarantee.
41+
async eval(script: string, _numkeys: number, k: string, expected: string, ttl?: string) {
3742
if (_store.get(k) !== expected) return 0;
43+
if (script.includes("expire")) {
44+
_ttls.set(k, Number(ttl));
45+
return 1;
46+
}
3847
_store.delete(k);
48+
_ttls.delete(k);
3949
return 1;
4050
},
41-
} as unknown as Redis & { _store: Map<string, string> };
51+
} as unknown as Redis & { _store: Map<string, string>; _ttls: Map<string, number> };
4252
}
4353

4454
describe("createRedisCache (#1216 webhook dedup cache)", () => {
@@ -81,6 +91,40 @@ describe("createRedisCache (#1216 webhook dedup cache)", () => {
8191
await expect(cache.claim("lock", "1", 60)).rejects.toThrow("connection refused");
8292
});
8393

94+
// #9467: renewal must be compare-and-extend for the same reason release is compare-and-delete -- a GET
95+
// followed by a separate EXPIRE could extend a key a NEW claimant wrote in between, handing the stale
96+
// holder's renewal to the live holder's lock.
97+
it("renewIfValue extends the TTL only when the stored value matches the caller's own token (#9467)", async () => {
98+
const redis = fakeRedis();
99+
const cache = createRedisCache(redis);
100+
await cache.claim!("lock", "holder-a", 600);
101+
expect(redis._ttls.get("lock")).toBe(600);
102+
103+
// The owner renews: TTL is reset, ownership unchanged.
104+
expect(await cache.renewIfValue!("lock", "holder-a", 900)).toBe(true);
105+
expect(redis._ttls.get("lock")).toBe(900);
106+
expect(redis._store.get("lock")).toBe("holder-a");
107+
108+
// A DIFFERENT holder's renewal must not touch it -- this is the double-actuation guard.
109+
expect(await cache.renewIfValue!("lock", "holder-b", 5)).toBe(false);
110+
expect(redis._ttls.get("lock")).toBe(900);
111+
});
112+
113+
it("renewIfValue returns false for a key that no longer exists, so a stale holder learns it lost (#9467)", async () => {
114+
const redis = fakeRedis();
115+
const cache = createRedisCache(redis);
116+
expect(await cache.renewIfValue!("never-claimed", "holder-a", 600)).toBe(false);
117+
});
118+
119+
it("renewIfValue does not resurrect a released lock (#9467)", async () => {
120+
const redis = fakeRedis();
121+
const cache = createRedisCache(redis);
122+
await cache.claim!("lock", "holder-a", 600);
123+
await cache.releaseIfValue!("lock", "holder-a");
124+
expect(await cache.renewIfValue!("lock", "holder-a", 600)).toBe(false);
125+
expect(redis._store.has("lock")).toBe(false);
126+
});
127+
84128
it("releaseIfValue deletes the key only when the stored value matches the caller's own token (#2129)", async () => {
85129
const r = fakeRedis();
86130
const cache = createRedisCache(r);

0 commit comments

Comments
 (0)