fix: clean up promoted repository lock on queued abort

This commit is contained in:
Nicolas Meienberger 2026-05-19 17:08:36 +02:00
parent b7ffd8dcbf
commit a662fe4d2c
No known key found for this signature in database
2 changed files with 40 additions and 2 deletions

View file

@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { eq } from "drizzle-orm";
import { db } from "~/server/db/db";
import { repositoryLocksTable, repositoryLockWaitersTable } from "~/server/db/schema";
@ -338,6 +338,43 @@ describe("RepositoryMutex", () => {
expect(repoMutex.isLocked(repoB)).toBe(false);
});
test("should not leave a promoted shared lock behind if that waiter aborts before observing acquisition", async () => {
vi.useFakeTimers();
const repoId = "shared-abort-after-promotion";
const releaseExclusive = await repoMutex.acquireExclusive(repoId, "holder");
const firstSharedPromise = repoMutex.acquireShared(repoId, "shared-1");
try {
await vi.advanceTimersByTimeAsync(100);
const controller = new AbortController();
const secondSharedPromise = repoMutex.acquireShared(repoId, "shared-2", controller.signal);
await vi.advanceTimersByTimeAsync(140);
releaseExclusive();
// The first shared waiter wakes first and promotes both shared waiters.
await vi.advanceTimersByTimeAsync(10);
const releaseShared1 = await firstSharedPromise;
controller.abort(new Error("abort after promotion"));
await expect(secondSharedPromise).rejects.toThrow("abort after promotion");
releaseShared1();
const remainingLocks = await db.query.repositoryLocksTable.findMany({
where: { repositoryId: { eq: repoId } },
orderBy: { operation: "asc" },
});
expect(remainingLocks).toEqual([]);
} finally {
await db.delete(repositoryLockWaitersTable).where(eq(repositoryLockWaitersTable.repositoryId, repoId));
await db.delete(repositoryLocksTable).where(eq(repositoryLocksTable.repositoryId, repoId));
vi.useRealTimers();
}
});
test("should safely handle multiple calls to the release function", async () => {
const repoId = "idempotent-release";

View file

@ -314,6 +314,7 @@ class RepositoryMutex {
} catch (error) {
this.stopHeartbeat(waiterId);
this.deleteWaiter(waiterId);
this.release({ id: waiterId });
throw error;
}
}
@ -412,7 +413,7 @@ class RepositoryMutex {
};
}
private release(lock: AcquiredLock) {
private release(lock: Pick<AcquiredLock, "id">) {
const releasedLock = db.transaction((tx) => {
const row = tx.query.repositoryLocksTable
.findFirst({ where: { AND: [{ id: { eq: lock.id } }, { ownerId: { eq: this.ownerId } }] } })