Skip to content

Commit d7aba34

Browse files
committed
fix(tests): eliminate empty image-occurrence-set race in integration seeding
Address intermittent CI failures caused by concurrent creation of the shared empty image occurrence set. Root cause: - integration helpers seeded ImageOccurrenceSet via upsert on the same occurrencesHash from concurrent test flows - under contention, Prisma intermittently surfaced P2002 unique-constraint errors, producing flaky API integration failures Changes: - add getOrCreateEmptyImageOccurrenceSet() in api-endpoints.integration.shared.ts - replace direct imageOccurrenceSet upsert in ensurePostVersionForSeed() with the new helper - add explicit unique-constraint handling (code P2002) and race recovery by reloading the raced row - keep helper promise-cached per process for the shared empty occurrence-set row to reduce duplicate concurrent create attempts - harden production-side createOrFindByUniqueConstraint in post.ts with bounded retry after unique-constraint races before rethrowing Validation: - pnpm typecheck passed - pnpm lint:ci passed - pnpm run test:integration:api passed - stress check: pnpm run test:integration:api passed 5 consecutive times
1 parent e9c8a1c commit d7aba34

2 files changed

Lines changed: 56 additions & 12 deletions

File tree

src/typescript/api/src/lib/trpc/routes/post.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ const WIKIPEDIA_HOST_REGEX = /^([a-z0-9-]+)(?:\.m)?\.wikipedia\.org$/i;
9393
const WIKIPEDIA_ARTICLE_PATH_PREFIX = "/wiki/";
9494
const WIKIPEDIA_INDEX_PATH_REGEX = /^\/w\/index\.php(?:[/?#]|$)/i;
9595
const WIKIPEDIA_PAGE_ID_REGEX = /^\d+$/;
96+
const UNIQUE_CONSTRAINT_RACE_RETRY_ATTEMPTS = 30;
97+
const UNIQUE_CONSTRAINT_RACE_RETRY_DELAY_MS = 20;
9698

9799
function unreachableInvestigationStatus(status: never): never {
98100
throw new TRPCError({
@@ -706,13 +708,17 @@ async function createOrFindByUniqueConstraint<T>(input: {
706708
if (!isUniqueConstraintError(error)) {
707709
throw error;
708710
}
709-
710-
const raced = await input.findExisting();
711-
if (raced === null) {
712-
throw error;
711+
// In concurrent transactions, the winning insert can briefly be invisible
712+
// to this transaction right after a unique-constraint conflict.
713+
for (let attempt = 0; attempt < UNIQUE_CONSTRAINT_RACE_RETRY_ATTEMPTS; attempt += 1) {
714+
const raced = await input.findExisting();
715+
if (raced !== null) {
716+
input.assertEquivalent(raced);
717+
return raced;
718+
}
719+
await new Promise((resolve) => setTimeout(resolve, UNIQUE_CONSTRAINT_RACE_RETRY_DELAY_MS));
713720
}
714-
input.assertEquivalent(raced);
715-
return raced;
721+
throw error;
716722
}
717723
}
718724

src/typescript/api/test/integration/api-endpoints.integration.shared.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -415,11 +415,54 @@ function sha256(input: string): string {
415415
}
416416

417417
const EMPTY_IMAGE_OCCURRENCES_HASH = sha256(JSON.stringify([]));
418+
let emptyImageOccurrenceSetPromise: Promise<{ id: string }> | null = null;
418419

419420
function versionHashFromContentHash(contentHash: string): string {
420421
return sha256(`${contentHash}\n${EMPTY_IMAGE_OCCURRENCES_HASH}`);
421422
}
422423

424+
function isPrismaUniqueConstraintError(error: unknown): boolean {
425+
return isNonNullObject(error) && error["code"] === "P2002";
426+
}
427+
428+
async function getOrCreateEmptyImageOccurrenceSet(): Promise<{ id: string }> {
429+
emptyImageOccurrenceSetPromise ??= (async () => {
430+
const existing = await prisma.imageOccurrenceSet.findUnique({
431+
where: { occurrencesHash: EMPTY_IMAGE_OCCURRENCES_HASH },
432+
select: { id: true },
433+
});
434+
if (existing !== null) {
435+
return existing;
436+
}
437+
438+
try {
439+
return await prisma.imageOccurrenceSet.create({
440+
data: { occurrencesHash: EMPTY_IMAGE_OCCURRENCES_HASH },
441+
select: { id: true },
442+
});
443+
} catch (error) {
444+
if (!isPrismaUniqueConstraintError(error)) {
445+
throw error;
446+
}
447+
const raced = await prisma.imageOccurrenceSet.findUnique({
448+
where: { occurrencesHash: EMPTY_IMAGE_OCCURRENCES_HASH },
449+
select: { id: true },
450+
});
451+
if (raced !== null) {
452+
return raced;
453+
}
454+
throw error;
455+
}
456+
})();
457+
458+
try {
459+
return await emptyImageOccurrenceSetPromise;
460+
} catch (error) {
461+
emptyImageOccurrenceSetPromise = null;
462+
throw error;
463+
}
464+
}
465+
423466
async function ensurePostVersionForSeed(input: {
424467
postId: string;
425468
contentHash: string;
@@ -444,12 +487,7 @@ async function ensurePostVersionForSeed(input: {
444487
},
445488
select: { id: true },
446489
}),
447-
prisma.imageOccurrenceSet.upsert({
448-
where: { occurrencesHash: EMPTY_IMAGE_OCCURRENCES_HASH },
449-
create: { occurrencesHash: EMPTY_IMAGE_OCCURRENCES_HASH },
450-
update: {},
451-
select: { id: true },
452-
}),
490+
getOrCreateEmptyImageOccurrenceSet(),
453491
]);
454492

455493
const now = new Date();

0 commit comments

Comments
 (0)