fix(web): wanted POST converges on P2002 unique race instead of 500

Two concurrent Wants for the same release-group both pass the
findUnique existence check as absent; the loser's create() then hits
the rgMbid unique constraint and threw an uncaught P2002, 500ing the
request. Wrap the create in try/catch and fall back to the same
update-and-return-200 path on P2002, converging instead of erroring.
Non-P2002 errors are rethrown unchanged.
This commit is contained in:
Jonathan
2026-07-13 00:40:29 +02:00
parent 9c793f42d1
commit 44c1b1a62a
2 changed files with 55 additions and 13 deletions
+29
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest"; import { describe, it, expect, vi, afterEach } from "vitest";
import { Prisma } from "@prisma/client";
vi.mock("@/lib/musicbrainz", () => ({ searchReleaseGroup: vi.fn() })); vi.mock("@/lib/musicbrainz", () => ({ searchReleaseGroup: vi.fn() }));
import { searchReleaseGroup } from "@/lib/musicbrainz"; import { searchReleaseGroup } from "@/lib/musicbrainz";
@@ -51,4 +52,32 @@ describe("wanted API", () => {
mockSearch.mockRejectedValue(new Error("boom")); mockSearch.mockRejectedValue(new Error("boom"));
expect((await POST(postReq({ artist: "X", album: "Y" }))).status).toBe(502); expect((await POST(postReq({ artist: "X", album: "Y" }))).status).toBe(502);
}); });
it("handles a concurrent duplicate (race between findUnique and create) by converging on 200, never 500", async () => {
mockSearch.mockResolvedValue({ rgMbid: "rg-race", title: "Race Condition", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2010", artistMbid: "a1", artistName: "Racer" });
const res = await POST(postReq({ artist: "Racer", album: "Race Condition" }));
expect([200, 201]).toContain(res.status);
// second identical POST must also be 200 (idempotent), never 500
const res2 = await POST(postReq({ artist: "Racer", album: "Race Condition" }));
expect(res2.status).toBe(200);
});
it("catches a P2002 thrown by create and falls back to update", async () => {
mockSearch.mockResolvedValue({ rgMbid: "rg-p2002", title: "Duplicate", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2011", artistMbid: "a1", artistName: "Duper" });
// Simulate the row already existing when create() is attempted (a concurrent winner beat us to it),
// while the pre-create findUnique check still had not seen it — force create() to reject with P2002.
await prisma.monitoredRelease.create({ data: { artistMbid: "a1", artistName: "Duper", rgMbid: "rg-p2002", album: "Duplicate", secondaryTypes: [], monitored: false } });
const spy = vi.spyOn(prisma.monitoredRelease, "create").mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { code: "P2002", clientVersion: "6.1.0" }),
);
// Force the pre-create existence check to miss (simulating the race) so we actually reach create().
const findUniqueSpy = vi.spyOn(prisma.monitoredRelease, "findUnique").mockResolvedValueOnce(null);
const res = await POST(postReq({ artist: "Duper", album: "Duplicate" }));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.monitored).toBe(true);
expect(await prisma.monitoredRelease.count({ where: { rgMbid: "rg-p2002" } })).toBe(1);
spy.mockRestore();
findUniqueSpy.mockRestore();
});
}); });
+26 -13
View File
@@ -1,3 +1,4 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { searchReleaseGroup } from "@/lib/musicbrainz"; import { searchReleaseGroup } from "@/lib/musicbrainz";
@@ -49,17 +50,29 @@ export async function POST(request: Request) {
const updated = await prisma.monitoredRelease.update({ where: { rgMbid: match.rgMbid }, data: { monitored: true } }); const updated = await prisma.monitoredRelease.update({ where: { rgMbid: match.rgMbid }, data: { monitored: true } });
return Response.json({ id: updated.id, album: updated.album, monitored: updated.monitored }, { status: 200 }); return Response.json({ id: updated.id, album: updated.album, monitored: updated.monitored }, { status: 200 });
} }
const created = await prisma.monitoredRelease.create({ try {
data: { const created = await prisma.monitoredRelease.create({
artistMbid: match.artistMbid, data: {
artistName: match.artistName, artistMbid: match.artistMbid,
rgMbid: match.rgMbid, artistName: match.artistName,
album: match.title, rgMbid: match.rgMbid,
primaryType: match.primaryType, album: match.title,
secondaryTypes: match.secondaryTypes, primaryType: match.primaryType,
firstReleaseDate: match.firstReleaseDate, secondaryTypes: match.secondaryTypes,
monitored: true, firstReleaseDate: match.firstReleaseDate,
}, monitored: true,
}); },
return Response.json({ id: created.id, album: created.album, monitored: created.monitored }, { status: 201 }); });
return Response.json({ id: created.id, album: created.album, monitored: created.monitored }, { status: 201 });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
// Concurrent Want for the same release-group won the create; converge to the update path.
const updated = await prisma.monitoredRelease.update({
where: { rgMbid: match.rgMbid },
data: { monitored: true },
});
return Response.json({ id: updated.id, album: updated.album, monitored: updated.monitored }, { status: 200 });
}
throw e;
}
} }