fix(dashboard): queue concurrent layout writes behind a row lock

Parallel PUTs to /api/dashboard/chart-overlay-prefs from one account ran
in Serializable transactions, which aborted all but one with a
serialization failure and answered 500. Lock the user row with FOR UPDATE
instead, so each writer waits for the previous commit and reads it. The
layout reset on DELETE /api/dashboard/widgets takes the same lock.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 17:42:00 +02:00
1 parent 09f84ef418
commit 86fa57d8bf
4 files changed
+234 -58

No files matched your search

+26 -25
View File
@@ -494,32 +494,33 @@ export const DELETE = apiHandler(async () => {
// dashboard to defaults is the loudest possible version of redecorating it.
const { user } = await requireAuth();
const { normalized, updatedAt } = await prisma.$transaction(
async (tx) => {
const existing = await tx.user.findUnique({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
});
const existingLayout = resolveDashboardLayout(
existing?.dashboardWidgetsJson,
);
const normalized = serializeDashboardLayout({
...existingLayout,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
enabledHeroItemKinds: DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds,
// A reset brings a hidden top card back.
todayCardVisible: DEFAULT_DASHBOARD_LAYOUT.todayCardVisible,
});
// Under a row lock rather than Serializable isolation: a reset racing a
// chart-overlay toggle on the same account waits for it instead of
// failing with a serialization error (a 500).
const { normalized, updatedAt } = await prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM users WHERE id = ${user.id} FOR UPDATE`;
const existing = await tx.user.findUnique({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
});
const existingLayout = resolveDashboardLayout(
existing?.dashboardWidgetsJson,
);
const normalized = serializeDashboardLayout({
...existingLayout,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
enabledHeroItemKinds: DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds,
// A reset brings a hidden top card back.
todayCardVisible: DEFAULT_DASHBOARD_LAYOUT.todayCardVisible,
});
const updated = await tx.user.update({
where: { id: user.id },
data: { dashboardWidgetsJson: toJson(normalized) },
select: { updatedAt: true },
});
return { normalized, updatedAt: updated.updatedAt.toISOString() };
},
{ isolationLevel: "Serializable" },
);
const updated = await tx.user.update({
where: { id: user.id },
data: { dashboardWidgetsJson: toJson(normalized) },
select: { updatedAt: true },
});
return { normalized, updatedAt: updated.updatedAt.toISOString() };
});
annotate({ action: { name: "dashboard.widgets.reset" } });