diff --git a/src/release-sync.ts b/src/release-sync.ts index 2911427..7a2f647 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,8 +291,10 @@ 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"; } @@ -326,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 51b3fbd..aa29e18 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: sync failed", + cause: expect.objectContaining({ code: "P2002" }), + }); + }); }); describe("scheduleReleaseSync", () => {