mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
01884e91fa
commit
46a1a5d320
4 files changed
+89
-41
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user