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

+8 -1
View File
@@ -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:
@@ -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 } },
},
},
},
},
@@ -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 = {