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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-07 01:33:19 +01:00
1 parent 01884e91fa
commit 46a1a5d320
4 files changed
+89 -41

No files matched your search

@@ -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(
+6 -1
View File
@@ -271,13 +271,18 @@ export const cycleFeedPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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 } },
},
},
},
},