Skip to content

Commit 5df591f

Browse files
committed
fix(queue): make claimPrActuationLock atomic, add a real concurrency test
Review findings on this PR: - claimPrActuationLock was a non-atomic getTransientKey-then-putTransientKey pair, so two genuinely concurrent deliveries for the same PR could both observe an absent key and both proceed — defeating the exact race this mutex exists to close. The sibling claimAgentMaintenanceLock (#2129, #2368) already solved this: env.SELFHOST_TRANSIENT_CACHE.claim performs the check-and-set as one atomic operation (Redis SET NX server-side), with a documented fallback to the old get/set pair for a cache adapter that hasn't implemented claim yet. Mirrored that exact pattern here. - The existing lock tests only pre-seeded the key before the call started, proving the contended branch but not the actual race. Added a Promise.all test that fires two draft-dodge deliveries for the SAME PR with neither pre-claiming anything, asserting exactly one PATCH and one completed audit row. Verified this test is meaningful by temporarily reverting to the non-atomic implementation and confirming it fails (2 PATCH calls), then restoring the fix and confirming it passes. - Exported claimPrActuationLock/releasePrActuationLock (matching the already-exported claimAgentMaintenanceLock/releaseAgentMaintenanceLock) and mirrored that sibling's full direct-unit-test suite — fail-open on a broken cache, fail-open when claim() itself throws, atomic-claim-used verification, and the no-claim-method fallback — closing the branch coverage gap the new code left in the fallback/catch paths.
1 parent 67dd74c commit 5df591f

2 files changed

Lines changed: 122 additions & 6 deletions

File tree

src/queue/processors.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2309,24 +2309,41 @@ async function putTransientKey(
23092309
// would read the same stale-but-still-"current" state, both pass their own freshness checks, and both
23102310
// independently fire a mutating call. This is a lightweight interim mutex (a full per-PR Durable Object /
23112311
// SubmissionLock is a separate, more-involved follow-up — see the TODO in env.d.ts) built on the SAME transient
2312-
// cache used for CI-completion coalescing above: a short-TTL claim, best-effort release. A lock-contended caller
2313-
// fails OPEN (returns false / skips this pass) rather than blocking — the delivery holding the lock is
2314-
// evaluating the SAME PR, and the periodic sweep is the backstop if this specific trigger is dropped.
2312+
// cache used for CI-completion coalescing above, claimed ATOMICALLY (see claimPrActuationLock) so two racing
2313+
// deliveries can never both win the claim — a short TTL, best-effort release. A lock-contended caller fails
2314+
// OPEN (returns false / skips this pass) rather than blocking — the delivery holding the lock is evaluating
2315+
// the SAME PR, and the periodic sweep is the backstop if this specific trigger is dropped.
23152316
const PR_ACTUATION_LOCK_TTL_SECONDS = 60;
23162317
function prActuationLockKey(repoFullName: string, prNumber: number): string {
23172318
return `pr-actuation-lock:${repoFullName.toLowerCase()}#${prNumber}`;
23182319
}
2319-
async function claimPrActuationLock(
2320+
export async function claimPrActuationLock(
23202321
env: Env,
23212322
repoFullName: string,
23222323
prNumber: number,
23232324
): Promise<boolean> {
23242325
const key = prActuationLockKey(repoFullName, prNumber);
2326+
// Atomic claim (#2129, mirroring claimAgentMaintenanceLock): a get-then-set pair has a window between the
2327+
// read and the write where two concurrent deliveries for the SAME PR can both observe an absent key and both
2328+
// claim it, defeating this mutex entirely. env.SELFHOST_TRANSIENT_CACHE.claim performs the check-and-set as
2329+
// one operation (Redis SET NX server-side), closing that window. Falls back to the non-atomic get/set pair
2330+
// only for a cache adapter that hasn't implemented claim yet — strictly no worse than this function's prior
2331+
// behavior.
2332+
if (env.SELFHOST_TRANSIENT_CACHE?.claim) {
2333+
try {
2334+
return await env.SELFHOST_TRANSIENT_CACHE.claim(key, "1", PR_ACTUATION_LOCK_TTL_SECONDS);
2335+
} catch {
2336+
return true; // fail open — see the doc comment above.
2337+
}
2338+
}
2339+
// getTransientKey/putTransientKey already fail open internally (a missing cache or a thrown read/write error
2340+
// both resolve rather than throw), so this never needs its own try/catch — a cache fault surfaces here as
2341+
// "no lock held", which correctly falls through to claiming it.
23252342
if (await getTransientKey(env, key)) return false;
23262343
await putTransientKey(env, key, "1", PR_ACTUATION_LOCK_TTL_SECONDS);
23272344
return true;
23282345
}
2329-
async function releasePrActuationLock(
2346+
export async function releasePrActuationLock(
23302347
env: Env,
23312348
repoFullName: string,
23322349
prNumber: number,

test/unit/queue.test.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import {
4444
upsertRepositoryFromGitHub,
4545
putCachedAiReview,
4646
} from "../../src/db/repositories";
47-
import { agentMaintenanceHeadMatchesGate, changedPathsForGuardrail, claimAgentMaintenanceLock, contributorEvidenceBatchSize, processJob, releaseAgentMaintenanceLock } from "../../src/queue/processors";
47+
import { agentMaintenanceHeadMatchesGate, changedPathsForGuardrail, claimAgentMaintenanceLock, claimPrActuationLock, contributorEvidenceBatchSize, processJob, releaseAgentMaintenanceLock, releasePrActuationLock } from "../../src/queue/processors";
4848
import { aiReviewCacheInputFingerprint } from "../../src/review/ai-review-cache-input";
4949
import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader";
5050
import { normalizeRegistryPayload } from "../../src/registry/normalize";
@@ -3453,6 +3453,74 @@ describe("queue processors", () => {
34533453
expect(await claimAgentMaintenanceLock(env, "owner/agent-repo", 7)).toBe(false);
34543454
});
34553455

3456+
// claimPrActuationLock (#2135) mirrors claimAgentMaintenanceLock's atomic-claim design exactly — same test
3457+
// shapes, same reasoning, a different lock namespace.
3458+
it("claimPrActuationLock claims when free, denies when held (per-PR), and release frees it again (#2135)", async () => {
3459+
const env = createTestEnv({});
3460+
expect(await claimPrActuationLock(env, "owner/act-repo", 7)).toBe(true);
3461+
expect(await claimPrActuationLock(env, "owner/act-repo", 7)).toBe(false);
3462+
expect(await claimPrActuationLock(env, "owner/act-repo", 8)).toBe(true);
3463+
await releasePrActuationLock(env, "owner/act-repo", 7);
3464+
expect(await claimPrActuationLock(env, "owner/act-repo", 7)).toBe(true);
3465+
});
3466+
3467+
it("claimPrActuationLock fails OPEN on a broken transient cache — never itself blocks actuation (#2135)", async () => {
3468+
const env = createTestEnv({
3469+
SELFHOST_TRANSIENT_CACHE: {
3470+
get: async () => { throw new Error("cache read error"); },
3471+
set: async () => { throw new Error("cache write error"); },
3472+
del: async () => { throw new Error("cache delete error"); },
3473+
},
3474+
});
3475+
expect(await claimPrActuationLock(env, "owner/act-repo", 7)).toBe(true);
3476+
await expect(releasePrActuationLock(env, "owner/act-repo", 7)).resolves.toBeUndefined();
3477+
});
3478+
3479+
it("claimPrActuationLock fails OPEN when the atomic claim primitive itself throws (#2135)", async () => {
3480+
const env = createTestEnv({
3481+
SELFHOST_TRANSIENT_CACHE: {
3482+
get: async () => null,
3483+
set: async () => undefined,
3484+
claim: async () => { throw new Error("redis unavailable"); },
3485+
},
3486+
});
3487+
expect(await claimPrActuationLock(env, "owner/act-repo", 7)).toBe(true);
3488+
});
3489+
3490+
it("REGRESSION (#2135): claimPrActuationLock uses an atomic check-and-set, so two genuinely concurrent claims for the SAME PR can never both succeed", async () => {
3491+
const env = createTestEnv({});
3492+
const [first, second] = await Promise.all([
3493+
claimPrActuationLock(env, "owner/act-repo", 7),
3494+
claimPrActuationLock(env, "owner/act-repo", 7),
3495+
]);
3496+
expect([first, second].filter(Boolean)).toHaveLength(1);
3497+
});
3498+
3499+
it("REGRESSION (#2135): claimPrActuationLock calls the atomic claim primitive, not a separate get+set pair, when the cache supports it", async () => {
3500+
const calls: string[] = [];
3501+
const env = createTestEnv({
3502+
SELFHOST_TRANSIENT_CACHE: {
3503+
get: async () => { calls.push("get"); return null; },
3504+
set: async () => { calls.push("set"); },
3505+
claim: async () => { calls.push("claim"); return true; },
3506+
},
3507+
});
3508+
expect(await claimPrActuationLock(env, "owner/act-repo", 7)).toBe(true);
3509+
expect(calls).toEqual(["claim"]); // never falls through to the racy get/set pair when claim is available
3510+
});
3511+
3512+
it("claimPrActuationLock falls back to the get/set pair and still denies a held key when the cache has no claim() (#2135)", async () => {
3513+
const values = new Map<string, string>();
3514+
const env = createTestEnv({
3515+
SELFHOST_TRANSIENT_CACHE: {
3516+
get: async (key: string) => values.get(key) ?? null,
3517+
set: async (key: string, value: string) => { values.set(key, value); },
3518+
},
3519+
});
3520+
expect(await claimPrActuationLock(env, "owner/act-repo", 7)).toBe(true);
3521+
expect(await claimPrActuationLock(env, "owner/act-repo", 7)).toBe(false);
3522+
});
3523+
34563524
it("INVARIANT (#2129 per-PR lock): a maintenance pass defers when another pass already holds the PR's lock", async () => {
34573525
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() });
34583526
await upsertInstallation(env, { action: "created", installation: { id: 9001, account: { login: "owner", id: 1, type: "Organization" }, target_type: "Organization", repository_selection: "selected", permissions: {}, events: [] } });
@@ -11892,6 +11960,37 @@ describe("converted_to_draft gate-close (draft-dodge prevention)", () => {
1189211960
expect(audit?.n).toBe(0); // no decision recorded either way — the in-flight delivery owns this pass
1189311961
});
1189411962

11963+
it("REGRESSION: exactly ONE of two genuinely concurrent draft-dodge deliveries for the SAME PR wins the actuation lock (#2135)", async () => {
11964+
// Unlike the lock-contended test above (which pre-seeds the key before the call even starts), this fires
11965+
// two deliveries together via Promise.all with NEITHER pre-claiming anything — exercising the actual
11966+
// check-and-set race claimPrActuationLock must arbitrate, not just "the key was already there". A
11967+
// get-then-set (non-atomic) implementation lets both deliveries observe an absent key and both proceed,
11968+
// which this test would catch as more than one PATCH / more than one completed audit row.
11969+
const calls: string[] = [];
11970+
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
11971+
const url = input.toString();
11972+
calls.push(`${init?.method ?? "GET"} ${url}`);
11973+
if (url.includes("/access_tokens")) return Response.json({ token: "t" });
11974+
if (url.endsWith("/issues/42/comments")) return Response.json({ id: 1 }, { status: 201 });
11975+
if (url.endsWith("/pulls/42")) return Response.json({ state: "closed" });
11976+
return new Response("not found", { status: 404 });
11977+
});
11978+
11979+
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" });
11980+
await setupRepo(env);
11981+
await recordGateBlockOutcome(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", blockerCodes: ["missing_linked_issue"] });
11982+
11983+
await Promise.all([
11984+
processJob(env, { type: "github-webhook", deliveryId: "draft-dodge-race-a", eventName: "pull_request", payload: draftPayload("contributor") }),
11985+
processJob(env, { type: "github-webhook", deliveryId: "draft-dodge-race-b", eventName: "pull_request", payload: draftPayload("contributor") }),
11986+
]);
11987+
11988+
const patchCalls = calls.filter((c) => c.includes("PATCH") && c.includes("/pulls/42"));
11989+
expect(patchCalls).toHaveLength(1); // exactly one delivery won the race and closed the PR
11990+
const audit = await env.DB.prepare("select count(*) as n from audit_events where event_type = ? and outcome = 'completed'").bind("github_app.draft_dodge_closed").first<{ n: number }>();
11991+
expect(audit?.n).toBe(1); // exactly one completed close recorded — not two (the race), not zero
11992+
});
11993+
1189511994
it("no-ops when no prior gate failure exists for the PR", async () => {
1189611995
const calls: string[] = [];
1189711996
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {

0 commit comments

Comments
 (0)