fix(server): order queue membership locks consistently

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-24 16:06:59 -07:00
1 parent e04a0375dc
commit 4d15c08428
4 files changed
+67 -1

No files matched your search

@@ -37,6 +37,8 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
afterEach(async () => {
await coordinator?.stop(); coordinator = undefined;
vi.useRealTimers(); vi.restoreAllMocks();
await db.execute(sql`drop trigger if exists approval_queue_delete_gate on decision_queue_items`);
await db.execute(sql`drop function if exists approval_queue_delete_gate()`);
await db.execute(sql`drop trigger if exists approval_queue_fail on decision_queue_items`);
await db.execute(sql`drop function if exists approval_queue_fail()`);
await db.delete(decisionTriageEvents); await db.delete(decisionQueueItems); await db.delete(decisionQueues);
@@ -290,4 +292,37 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
await start(); await quiet(); expect(await memberships()).toHaveLength(2);
});
it("serializes manual removal and automatic seeding without a queue/item lock cycle", async () => {
const approval = await pending();
const [link] = await db.select().from(issueApprovals).where(eq(issueApprovals.approvalId, approval.id));
const queues = decisionQueueService(db);
const seed = () => queues.materializeSeedSources(companyId, [{ sourceKind: "approval", sourceId: approval.id, issueId: link.issueId }]);
await seed();
await db.execute(sql`create function approval_queue_delete_gate() returns trigger language plpgsql as $$ begin
perform pg_advisory_xact_lock(hashtext(TG_ARGV[0])); return old; end $$`);
await db.execute(sql`create trigger approval_queue_delete_gate after delete on decision_queue_items for each row execute function approval_queue_delete_gate(${sql.raw("'" + companyId + "'")})`);
const blocker = await db.$client.reserve();
await blocker`select pg_advisory_lock(hashtext(${companyId}))`;
let complete: Promise<PromiseSettledResult<unknown>[]> | undefined;
try {
const removed = queues.removeItem({ ...mutation(), sourceKind: "approval", sourceId: approval.id });
await vi.waitFor(async () => {
const [row] = await db.execute<{ count: number }>(sql`select count(*)::int as count from pg_stat_activity
where datname = current_database() and state = 'active' and wait_event = 'advisory'`);
expect(row.count).toBeGreaterThan(0);
}, { timeout: 5000 });
complete = Promise.allSettled([removed, seed()]);
await vi.waitFor(async () => {
const [row] = await db.execute<{ count: number }>(sql`select count(*)::int as count from pg_stat_activity
where datname = current_database() and state = 'active' and wait_event_type = 'Lock' and wait_event <> 'advisory'`);
expect(row.count).toBeGreaterThan(0);
}, { timeout: 5000 });
} finally {
await blocker`select pg_advisory_unlock_all()`; blocker.release();
}
const results = await complete!;
expect(results.map(result => result.status), results.filter(result => result.status === "rejected").map(result => String(result.reason?.cause?.message ?? result.reason)).join("; ")).toEqual(["fulfilled", "fulfilled"]);
expect(await memberships()).toHaveLength(1);
});
});
+8
View File
@@ -376,6 +376,12 @@ export function decisionQueueService(db: Db) {
.then((rows) => rows[0] ?? null);
}
async function lockQueue(database: Db, companyId: string, id: string) {
const [queue] = await database.select({ id: decisionQueues.id }).from(decisionQueues)
.where(and(eq(decisionQueues.companyId, companyId), eq(decisionQueues.id, id))).for("update");
if (!queue) throw notFound("Decision queue not found");
}
async function visibleItems(companyId: string, queueId: string, authActor: AuthorizationActor) {
const rows = await db.select().from(decisionQueueItems)
.where(and(eq(decisionQueueItems.companyId, companyId), eq(decisionQueueItems.queueId, queueId)))
@@ -600,6 +606,7 @@ export function decisionQueueService(db: Db) {
await requireSourceRead(db, input.authActor, input.companyId, input.sourceKind, input.sourceId);
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
await lockQueue(txDb, input.companyId, queue.id);
const inserted = await txDb.insert(decisionQueueItems).values({
companyId: input.companyId,
queueId: queue.id,
@@ -665,6 +672,7 @@ export function decisionQueueService(db: Db) {
}
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
await lockQueue(txDb, input.companyId, queue.id);
if (input.key === "prs" && input.sourceKind === "approval") await notifyApprovalQueueWork(txDb);
const removed = await txDb.delete(decisionQueueItems).where(and(
eq(decisionQueueItems.companyId, input.companyId),