mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
fix(server): order queue membership locks consistently
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user