From a212e444d6373fd931831d543e3e7272bbad8d78 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Sat, 19 Sep 2026 14:01:00 +0200 Subject: [PATCH 1/2] fix(release-sync): only treat a unique violation as a race when the row exists Sync caught every P2002 from the release insert as "created concurrently elsewhere". On staging the id sequences were behind the rows after a data import, so each insert failed on the primary key, was logged as a race, and left nothing in the table. After a unique violation, createRelease now looks the (version, type) row up. Present means another instance registered it first; absent means the insert really failed, and the error is rethrown with the type and version in its message so the scheduled run log names the release. --- src/release-sync.ts | 20 +++++++++++++++++--- test/sync-releases.test.ts | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/release-sync.ts b/src/release-sync.ts index 2911427..b750462 100644 --- a/src/release-sync.ts +++ b/src/release-sync.ts @@ -216,6 +216,18 @@ async function listSyncedVersions(prisma: PrismaClient, type: ReleaseType): Prom return new Set(releases.map(release => release.version)); } +async function releaseExists( + prisma: PrismaClient, + type: ReleaseType, + version: string, +): Promise { + const release = await prisma.release.findUnique({ + where: { version_type: { version, type } }, + select: { id: true }, + }); + return release !== null; +} + function isUniqueViolation(error: unknown): boolean { return ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002" @@ -279,12 +291,14 @@ async function createRelease( }); } catch (error) { // Another API instance can win the race between the version listing and - // this insert. The row it wrote is the one we wanted, so treat it as synced. - if (isUniqueViolation(error)) { + // this insert, in which case the row it wrote is the one we wanted. Any + // other unique violation (a stale id sequence after a data import, say) + // leaves no row and is a real failure. + if (isUniqueViolation(error) && (await releaseExists(clients.prisma, type, version))) { console.log(`[sync-releases] ${type} ${version}: created concurrently elsewhere, skipping`); return "already-synced"; } - throw error; + throw new Error(`[sync-releases] ${type} ${version}: create failed`, { cause: error }); } console.log( diff --git a/test/sync-releases.test.ts b/test/sync-releases.test.ts index 51b3fbd..1f5c59c 100644 --- a/test/sync-releases.test.ts +++ b/test/sync-releases.test.ts @@ -4,7 +4,7 @@ import { ListObjectsV2Command, S3Client, } from "@aws-sdk/client-s3"; -import { PrismaClient } from "@prisma/client"; +import { Prisma, PrismaClient } from "@prisma/client"; import { afterEach, describe, expect, beforeEach, it, vi } from "vitest"; import { @@ -376,6 +376,7 @@ describe("syncReleases", () => { const racingPrisma = { release: { findMany: async () => [], + findUnique: (args: unknown) => testPrisma.release.findUnique(args as any), create: (args: unknown) => testPrisma.release.create(args as any), }, } as unknown as PrismaClient; @@ -392,6 +393,40 @@ describe("syncReleases", () => { }); expect(release.url).toBe("https://cdn.test.com/other-instance"); }); + + it("fails on a unique violation that left no release row behind", async () => { + const version = "9.9.9"; + mockS3ListVersions("app", [version]); + mockS3HashFile("app", version, "app-hash"); + + // A stale id sequence (a database restored from a dump) makes the insert + // fail on the primary key with the same error code as the race above, + // but no row for this version exists afterwards. + const stalePrisma = { + release: { + findMany: async () => [], + findUnique: async () => null, + create: async () => { + throw new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + clientVersion: "test", + meta: { target: ["id"] }, + }); + }, + }, + } as unknown as PrismaClient; + + await expect( + syncReleases( + { prisma: stalePrisma, s3Client: syncS3Client }, + { bucketName: SYNC_BUCKET, baseUrl: SYNC_BASE_URL }, + createAtDefaultRollout, + ), + ).rejects.toMatchObject({ + message: "[sync-releases] app 9.9.9: create failed", + cause: expect.objectContaining({ code: "P2002" }), + }); + }); }); describe("scheduleReleaseSync", () => { From 2f4a51d617fda9fa12e1ff7c1417c03505073601 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Sat, 19 Sep 2026 14:27:13 +0200 Subject: [PATCH 2/2] fix(release-sync): name the release in every per-version failure Wrapping only the insert error left the row lookup, and the S3 scan before it, free to escape without the type and version. syncReleases now wraps whatever createRelease throws for a version. --- src/release-sync.ts | 8 ++++++-- test/sync-releases.test.ts | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/release-sync.ts b/src/release-sync.ts index b750462..7a2f647 100644 --- a/src/release-sync.ts +++ b/src/release-sync.ts @@ -298,7 +298,7 @@ async function createRelease( console.log(`[sync-releases] ${type} ${version}: created concurrently elsewhere, skipping`); return "already-synced"; } - throw new Error(`[sync-releases] ${type} ${version}: create failed`, { cause: error }); + throw error; } console.log( @@ -340,9 +340,13 @@ export async function syncReleases( ]); for (const version of versions) { + // Name the release in any failure, whichever step raised it, so the + // scheduled run log does not need to be traced back to a version. const outcome = synced.has(version) ? "already-synced" - : await createRelease(clients, config, decide, type, version); + : await createRelease(clients, config, decide, type, version).catch((error: unknown) => { + throw new Error(`[sync-releases] ${type} ${version}: sync failed`, { cause: error }); + }); stats[outcome]++; if (outcome === "aborted") { diff --git a/test/sync-releases.test.ts b/test/sync-releases.test.ts index 1f5c59c..aa29e18 100644 --- a/test/sync-releases.test.ts +++ b/test/sync-releases.test.ts @@ -423,7 +423,7 @@ describe("syncReleases", () => { createAtDefaultRollout, ), ).rejects.toMatchObject({ - message: "[sync-releases] app 9.9.9: create failed", + message: "[sync-releases] app 9.9.9: sync failed", cause: expect.objectContaining({ code: "P2002" }), }); });