From 46a1a5d3200847e05ad7e8e32f2d985c10d11663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 7 Nov 2026 01:26:12 +0100 Subject: [PATCH] fix(cycle): answer a raced feed rotation with 409 instead of 500 Two rotations at once both revoked the same live feed, and the second successor then hit the one-live-feed index with an unhandled unique violation. The losing rotation now gets the mint path's 409 cycle.feed.exists, and the winner's link stands. --- docs/api/openapi.yaml | 9 +- .../cycle/feed-subscription/rotate/route.ts | 89 +++++++++++-------- src/lib/openapi/routes/cycle-feed.ts | 7 +- tests/integration/cycle-calendar-feed.test.ts | 25 ++++++ 4 files changed, 89 insertions(+), 41 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6bedef489..5af7d6c92 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -958,7 +958,8 @@ paths: - Cycle summary: Replace the calendar subscription's link (#1350) description: "One transaction: the live link is revoked and a new one minted with the same options. Returns the new URL - EXACTLY ONCE. Calendars using the old link stop updating." + EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, the one that loses answers + 409 `cycle.feed.exists` and the winner's link stands." responses: "201": *a13 "401": *a14 @@ -969,6 +970,12 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: Another rotation replaced the link at the same moment (`cycle.feed.exists`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/measurement-categories: diff --git a/src/app/api/cycle/feed-subscription/rotate/route.ts b/src/app/api/cycle/feed-subscription/rotate/route.ts index 4f6705ae1..9b62851c9 100644 --- a/src/app/api/cycle/feed-subscription/rotate/route.ts +++ b/src/app/api/cycle/feed-subscription/rotate/route.ts @@ -5,7 +5,10 @@ * One transaction: the live feed is revoked and its successor minted with the * same options, so there is never a moment with two live links or with none. * Calendars holding the old link stop updating on their next poll. The new - * URL is returned exactly once. + * URL is returned exactly once. Two rotations racing both revoke the same + * feed, and the second successor then meets the one-live-feed index: that + * one gets the mint path's 409 `cycle.feed.exists`, and the winner's link + * stands. * * Same gates as turning the subscription on: cookie session only, the owner or * the guardian of a managed profile (`requireGuardianAuth`), a fresh proof, @@ -26,6 +29,7 @@ import { FEED_MINT_LIMIT_PER_HOUR, FEED_MINT_WINDOW_MS, cookieOnlyRefusal, + feedExists, feedLinks, noLiveFeed, } from "@/lib/cycle/feed/manage"; @@ -34,6 +38,7 @@ import { mintFeedToken } from "@/lib/cycle/feed/token"; import { prisma } from "@/lib/db"; import { resolveOrigin } from "@/lib/http/origin"; import { annotate } from "@/lib/logging/context"; +import { isP2002 } from "@/lib/prisma-errors"; import { checkRateLimit } from "@/lib/rate-limit"; export const POST = apiHandler(async (request: NextRequest) => { @@ -63,46 +68,52 @@ export const POST = apiHandler(async (request: NextRequest) => { } const minted = mintFeedToken(); - const rotated = await prisma.$transaction(async (tx) => { - const live = await tx.cycleCalendarFeed.findFirst({ - where: { userId: user.id, revokedAt: null }, - select: { - id: true, - horizonCycles: true, - historyCycles: true, - includeFertile: true, - titleMode: true, - }, - }); - if (!live) return null; - await tx.cycleCalendarFeed.update({ - where: { id: live.id }, - data: { revokedAt: new Date() }, - }); - const created = await tx.cycleCalendarFeed.create({ - data: { + const rotated = await prisma + .$transaction(async (tx) => { + const live = await tx.cycleCalendarFeed.findFirst({ + where: { userId: user.id, revokedAt: null }, + select: { + id: true, + horizonCycles: true, + historyCycles: true, + includeFertile: true, + titleMode: true, + }, + }); + if (!live) return null; + await tx.cycleCalendarFeed.update({ + where: { id: live.id }, + data: { revokedAt: new Date() }, + }); + const created = await tx.cycleCalendarFeed.create({ + data: { + userId: user.id, + createdById: actor.id, + tokenHash: minted.hash, + tokenPrefix: minted.prefix, + horizonCycles: live.horizonCycles, + historyCycles: live.historyCycles, + includeFertile: live.includeFertile, + titleMode: live.titleMode, + }, + select: FEED_SUMMARY_SELECT, + }); + await auditLog("cycle.feed.rotate", { userId: user.id, - createdById: actor.id, - tokenHash: minted.hash, - tokenPrefix: minted.prefix, - horizonCycles: live.horizonCycles, - historyCycles: live.historyCycles, - includeFertile: live.includeFertile, - titleMode: live.titleMode, - }, - select: FEED_SUMMARY_SELECT, + details: { + feedId: created.id, + previousFeedId: live.id, + tokenPrefix: created.tokenPrefix, + }, + client: tx, + }); + return created; + }) + .catch((err: unknown) => { + if (isP2002(err)) return "raced" as const; + throw err; }); - await auditLog("cycle.feed.rotate", { - userId: user.id, - details: { - feedId: created.id, - previousFeedId: live.id, - tokenPrefix: created.tokenPrefix, - }, - client: tx, - }); - return created; - }); + if (rotated === "raced") return feedExists(); if (!rotated) return noLiveFeed(); return apiSuccess( diff --git a/src/lib/openapi/routes/cycle-feed.ts b/src/lib/openapi/routes/cycle-feed.ts index a8ee0a7b3..3e250c994 100644 --- a/src/lib/openapi/routes/cycle-feed.ts +++ b/src/lib/openapi/routes/cycle-feed.ts @@ -271,13 +271,18 @@ export const cycleFeedPaths: NonNullable = { tags: ["Cycle"], summary: "Replace the calendar subscription's link (#1350)", description: - "One transaction: the live link is revoked and a new one minted with the same options. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating.", + "One transaction: the live link is revoked and a new one minted with the same options. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, the one that loses answers 409 `cycle.feed.exists` and the winner's link stands.", responses: { ...mintResponses, "404": { description: "No subscription is on (`cycle.feed.none`).", content: { "application/json": { schema: errorEnvelope } }, }, + "409": { + description: + "Another rotation replaced the link at the same moment (`cycle.feed.exists`).", + content: { "application/json": { schema: errorEnvelope } }, + }, }, }, }, diff --git a/tests/integration/cycle-calendar-feed.test.ts b/tests/integration/cycle-calendar-feed.test.ts index 391ba30c5..b615b2755 100644 --- a/tests/integration/cycle-calendar-feed.test.ts +++ b/tests/integration/cycle-calendar-feed.test.ts @@ -212,6 +212,31 @@ describe("turning the subscription on", () => { ).toBe(1); }); + it("answers the losing one of two racing rotations with 409, never 500", async () => { + const owner = await makeUser("owner"); + await mint(owner.id); + // Both read the same live feed and revoke it; the second successor then + // meets the one-live-feed index. Four rounds stay inside the mint bucket. + const statuses: number[] = []; + for (let round = 0; round < 4; round++) { + const pair = await Promise.all([rotate(), rotate()]); + for (const res of pair) { + statuses.push(res.status); + if (res.status === 409) { + expect((await res.json()).meta.errorCode).toBe("cycle.feed.exists"); + } + } + expect( + await db().cycleCalendarFeed.count({ + where: { userId: owner.id, revokedAt: null }, + }), + ).toBe(1); + } + expect(statuses.every((s) => s === 201 || s === 409)).toBe(true); + // The race was really run, not sidestepped by one finishing first. + expect(statuses).toContain(409); + }); + it("the partial unique index rejects a second live row written directly", async () => { const owner = await makeUser("owner"); const base = {