Skip to content

Commit eca3b61

Browse files
authored
queue(locks): register the actuation and contributor-cap locks in the shutdown held-lock registry (#10067)
Fixes #10021 Co-authored-by: phamngocquy <phamngocquy@users.noreply.github.com>
1 parent 32e3886 commit eca3b61

2 files changed

Lines changed: 174 additions & 14 deletions

File tree

src/queue/transient-locks.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
// delete a later claimer's live lock (#2129/#2135).
2121

2222
import { randomUUID } from "node:crypto";
23+
import { registerHeldLock, unregisterHeldLock } from "./held-lock-registry";
2324
import { RetryableJobError } from "./retryable";
2425

2526
/** Result of a transient-lock claim attempt. `ownerToken` is the random value THIS call wrote when it actually
@@ -212,19 +213,22 @@ export async function claimPrActuationLock(
212213
repoFullName: string,
213214
prNumber: number,
214215
): Promise<TransientLockClaim> {
215-
return claimTransientLock(
216-
env,
217-
prActuationLockKey(repoFullName, prNumber),
218-
PR_ACTUATION_LOCK_TTL_SECONDS,
219-
);
216+
const key = prActuationLockKey(repoFullName, prNumber);
217+
const claim = await claimTransientLock(env, key, PR_ACTUATION_LOCK_TTL_SECONDS);
218+
if (claim.acquired && claim.ownerToken !== null) {
219+
registerHeldLock(key, claim.ownerToken, () => releaseTransientLockIfOwner(env, key, claim.ownerToken));
220+
}
221+
return claim;
220222
}
221223
export async function releasePrActuationLock(
222224
env: Env,
223225
repoFullName: string,
224226
prNumber: number,
225227
ownerToken: string | null,
226228
): Promise<void> {
227-
await releaseTransientLockIfOwner(env, prActuationLockKey(repoFullName, prNumber), ownerToken);
229+
const key = prActuationLockKey(repoFullName, prNumber);
230+
await releaseTransientLockIfOwner(env, key, ownerToken);
231+
if (ownerToken !== null) unregisterHeldLock(key, ownerToken);
228232
}
229233

230234
// A plain thrown Error still reaches the queue's retry path (this call site is deliberately uncaught, same as
@@ -266,17 +270,20 @@ export async function claimContributorCapLock(
266270
repoFullName: string,
267271
authorLogin: string,
268272
): Promise<TransientLockClaim> {
269-
return claimTransientLock(
270-
env,
271-
contributorCapLockKey(repoFullName, authorLogin),
272-
CONTRIBUTOR_CAP_LOCK_TTL_SECONDS,
273-
);
273+
const key = contributorCapLockKey(repoFullName, authorLogin);
274+
const claim = await claimTransientLock(env, key, CONTRIBUTOR_CAP_LOCK_TTL_SECONDS);
275+
if (claim.acquired && claim.ownerToken !== null) {
276+
registerHeldLock(key, claim.ownerToken, () => releaseTransientLockIfOwner(env, key, claim.ownerToken));
277+
}
278+
return claim;
274279
}
275280
export async function releaseContributorCapLock(
276281
env: Env,
277282
repoFullName: string,
278283
authorLogin: string,
279284
ownerToken: string | null,
280285
): Promise<void> {
281-
await releaseTransientLockIfOwner(env, contributorCapLockKey(repoFullName, authorLogin), ownerToken);
286+
const key = contributorCapLockKey(repoFullName, authorLogin);
287+
await releaseTransientLockIfOwner(env, key, ownerToken);
288+
if (ownerToken !== null) unregisterHeldLock(key, ownerToken);
282289
}

test/unit/transient-locks.test.ts

Lines changed: 155 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { heldLockCountForTest, releaseAllHeldLocksAtShutdown } from "../../src/queue/held-lock-registry";
23
import { SubmissionLock } from "../../src/queue/submission-lock";
34
import {
45
claimContributorCapLock,
@@ -501,15 +502,18 @@ describe("domain wrappers + PrActuationLockContendedError (#8896)", () => {
501502
});
502503
delete env.SUBMISSION_LOCK;
503504

504-
await claimPrActuationLock(env, "acme/widgets", 7);
505+
const prClaim = await claimPrActuationLock(env, "acme/widgets", 7);
505506
const [actuationTtl] = ttlCalls;
506507
ttlCalls.length = 0;
507508

508-
await claimContributorCapLock(env, "acme/widgets", "alice");
509+
const capClaim = await claimContributorCapLock(env, "acme/widgets", "alice");
509510
const [capTtl] = ttlCalls;
510511

511512
expect(capTtl).toBe(actuationTtl);
512513
expect(capTtl).toBe(600);
514+
515+
await releasePrActuationLock(env, "acme/widgets", 7, prClaim.ownerToken);
516+
await releaseContributorCapLock(env, "acme/widgets", "alice", capClaim.ownerToken);
513517
});
514518

515519
it("builds a fast-retry contended error with a distinct retryKind", () => {
@@ -841,3 +845,152 @@ describe("lock heartbeat end-to-end (#9467)", () => {
841845
expect((await claimTransientLock(env, "lock:pr", 60)).acquired).toBe(true);
842846
});
843847
});
848+
849+
// #10021: register actuation and contributor-cap locks in the shutdown held-lock registry (same shape as
850+
// claimAiReviewLock / releaseAiReviewLock in ai-review-orchestration.ts).
851+
describe("claimPrActuationLock / releasePrActuationLock register with the held-lock registry (#10021)", () => {
852+
afterEach(async () => {
853+
await releaseAllHeldLocksAtShutdown();
854+
});
855+
856+
function cacheWithReleaseTracking() {
857+
const releases: Array<{ key: string; value: string }> = [];
858+
const held = new Map<string, string>();
859+
const cache = {
860+
get: async (key: string) => held.get(key) ?? null,
861+
set: async () => undefined,
862+
claim: async (key: string, value: string) => {
863+
if (held.has(key)) return false;
864+
held.set(key, value);
865+
return true;
866+
},
867+
releaseIfValue: async (key: string, value: string) => {
868+
releases.push({ key, value });
869+
if (held.get(key) !== value) return false;
870+
held.delete(key);
871+
return true;
872+
},
873+
};
874+
return { cache, releases };
875+
}
876+
877+
it("registers a real claim, and shutdown release issues releaseIfValue for the pr-actuation-lock key", async () => {
878+
const { cache, releases } = cacheWithReleaseTracking();
879+
const env = createTestEnv({ SELFHOST_TRANSIENT_CACHE: cache });
880+
delete env.SUBMISSION_LOCK;
881+
882+
const before = heldLockCountForTest();
883+
const claim = await claimPrActuationLock(env, "acme/widgets", 7);
884+
expect(claim.acquired).toBe(true);
885+
expect(heldLockCountForTest()).toBe(before + 1);
886+
expect(releases).toHaveLength(0);
887+
888+
expect(await releaseAllHeldLocksAtShutdown()).toBe(before + 1);
889+
expect(releases).toEqual([
890+
{ key: "pr-actuation-lock:acme/widgets#7", value: claim.ownerToken },
891+
]);
892+
});
893+
894+
it("unregisters on the normal release path with a matching owner token", async () => {
895+
const env = createTestEnv();
896+
const before = heldLockCountForTest();
897+
const claim = await claimPrActuationLock(env, "acme/widgets", 8);
898+
await releasePrActuationLock(env, "acme/widgets", 8, claim.ownerToken);
899+
expect(heldLockCountForTest()).toBe(before);
900+
});
901+
902+
it("does not register a fail-open claim — there is nothing real to release", async () => {
903+
const env = createTestEnv();
904+
delete (env as { SELFHOST_TRANSIENT_CACHE?: unknown }).SELFHOST_TRANSIENT_CACHE;
905+
const before = heldLockCountForTest();
906+
const claim = await claimPrActuationLock(env, "acme/widgets", 10);
907+
expect(claim.ownerToken).toBeNull();
908+
expect(heldLockCountForTest()).toBe(before);
909+
});
910+
911+
it("a null-token release does not unregister a real held entry", async () => {
912+
const env = createTestEnv();
913+
const before = heldLockCountForTest();
914+
await claimPrActuationLock(env, "acme/widgets", 11);
915+
await releasePrActuationLock(env, "acme/widgets", 11, null);
916+
expect(heldLockCountForTest()).toBe(before + 1);
917+
});
918+
919+
it("#10021 releasePrActuationLock with a non-matching ownerToken does not evict the registry entry (#9468)", async () => {
920+
const env = createTestEnv();
921+
const before = heldLockCountForTest();
922+
await claimPrActuationLock(env, "acme/widgets", 9);
923+
expect(heldLockCountForTest()).toBe(before + 1);
924+
await releasePrActuationLock(env, "acme/widgets", 9, "tok-someone-else");
925+
expect(heldLockCountForTest()).toBe(before + 1);
926+
});
927+
});
928+
929+
describe("claimContributorCapLock / releaseContributorCapLock register with the held-lock registry (#10021)", () => {
930+
afterEach(async () => {
931+
await releaseAllHeldLocksAtShutdown();
932+
});
933+
934+
function cacheWithReleaseTracking() {
935+
const releases: Array<{ key: string; value: string }> = [];
936+
const held = new Map<string, string>();
937+
const cache = {
938+
get: async (key: string) => held.get(key) ?? null,
939+
set: async () => undefined,
940+
claim: async (key: string, value: string) => {
941+
if (held.has(key)) return false;
942+
held.set(key, value);
943+
return true;
944+
},
945+
releaseIfValue: async (key: string, value: string) => {
946+
releases.push({ key, value });
947+
if (held.get(key) !== value) return false;
948+
held.delete(key);
949+
return true;
950+
},
951+
};
952+
return { cache, releases };
953+
}
954+
955+
it("registers a real claim, and shutdown release issues releaseIfValue for the contributor-cap-lock key", async () => {
956+
const { cache, releases } = cacheWithReleaseTracking();
957+
const env = createTestEnv({ SELFHOST_TRANSIENT_CACHE: cache });
958+
delete env.SUBMISSION_LOCK;
959+
960+
const before = heldLockCountForTest();
961+
const claim = await claimContributorCapLock(env, "Acme/Widgets", "Alice");
962+
expect(claim.acquired).toBe(true);
963+
expect(heldLockCountForTest()).toBe(before + 1);
964+
expect(releases).toHaveLength(0);
965+
966+
expect(await releaseAllHeldLocksAtShutdown()).toBe(before + 1);
967+
expect(releases).toEqual([
968+
{ key: "contributor-cap-lock:acme/widgets:alice", value: claim.ownerToken },
969+
]);
970+
});
971+
972+
it("unregisters on the normal release path with a matching owner token", async () => {
973+
const env = createTestEnv();
974+
const before = heldLockCountForTest();
975+
const claim = await claimContributorCapLock(env, "acme/widgets", "bob");
976+
await releaseContributorCapLock(env, "acme/widgets", "bob", claim.ownerToken);
977+
expect(heldLockCountForTest()).toBe(before);
978+
});
979+
980+
it("does not register a fail-open claim — there is nothing real to release", async () => {
981+
const env = createTestEnv();
982+
delete (env as { SELFHOST_TRANSIENT_CACHE?: unknown }).SELFHOST_TRANSIENT_CACHE;
983+
const before = heldLockCountForTest();
984+
const claim = await claimContributorCapLock(env, "acme/widgets", "dave");
985+
expect(claim.ownerToken).toBeNull();
986+
expect(heldLockCountForTest()).toBe(before);
987+
});
988+
989+
it("a null-token release does not unregister a real held entry", async () => {
990+
const env = createTestEnv();
991+
const before = heldLockCountForTest();
992+
await claimContributorCapLock(env, "acme/widgets", "carol");
993+
await releaseContributorCapLock(env, "acme/widgets", "carol", null);
994+
expect(heldLockCountForTest()).toBe(before + 1);
995+
});
996+
});

0 commit comments

Comments
 (0)