采购提交与报销新建/提交事务显式使用10秒上限 [09-27-purchase-submit-tx-timeout]

负载下采购提交事务超过 Prisma 交互事务默认 5000ms(P2028)返回 500。
分段计时显示无单一瓶颈(总时长中位 843ms、最大 3506ms,编号锁等待
最多 535ms),是约 20 条顺序查询整体变慢贴近上限。报销新建/提交同结构。
三处改用项目既有的 { maxWait: 5000, timeout: 10000 },事务内容不变;
新增采购与报销提交的事务选项回归断言。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-09-27 12:27:04 +08:00
共同撰写人 Claude Opus 5.5
父节点 a6ff2dc3b1
当前提交 b6f0c83608
共修改 9 个文件,包含 92 行新增和 4 行删除
@@ -0,0 +1,7 @@
## 2026-09-27 实施(当前)
- [x] 临时分段计时(未提交,已还原)定位:事务总时长中位 843ms/最大 3506ms,无单一瓶颈,编号锁等待最多 535ms。
- [x] 采购提交、报销新建、报销提交三处 `$transaction` 显式 `{ maxWait: 5000, timeout: 10000 }`;事务内容不变。
- [x] 回归断言:采购提交(去掉修复即失败)、报销提交。
- [x] 后端全量单测:4451 通过 / 1 原跳过。
- [ ] 负载复现确认无采购提交 P2028;随下次发布候选走完整门禁。
@@ -0,0 +1,33 @@
# 采购提交事务负载下超时返回500
## Goal
消除采购提交(及同结构的报销新建/提交)在负载下超过 Prisma 交互事务默认 5000ms 上限而返回 500(P2028)的问题。
## Background / Evidence(2026-09-26/27)
- 09-26 负载复现(采购 ×6–8 + permission-direct-access ×6–8,4 worker,2 核主机)每轮出现 1–2 次 `purchase submit failed: 500`,服务端为 `P2028 Transaction API error ... timeout 5000 ms, 5352 ms passed`,位于 `server/src/routes/purchaseRequests.js` 提交事务内的 `workflowTask.create`。正式门禁 6 轮未出现。
- 生产 `b1d25d3c` 与当前代码同写法(默认超时),非近期回归。
- 09-27 事务内分段计时(16 次提交全部成功):锁定单据中位 41/最大 290ms;编号前校验与预检 466/1659ms;编号分配含锁等待 101/535ms;编号后至提交 317/2194ms;**事务总时长中位 843ms、最大 3506ms**。无单一瓶颈:编号行锁等待最多 0.5s,主要是约 20 条顺序查询在负载下整体变慢,贴近 5s 上限。
- 报销新建(`POST /reimbursements`)与提交(`/reimbursements/:id/submit`)同样在默认超时事务内分配编号、执行多步写入,属同一风险。
## Scope
- 采购提交、报销新建、报销提交三处交互事务显式使用项目既有写法 `{ maxWait: 5000, timeout: 10000 }`(付款批次、回执 OCR 等已采用)。
- 不改事务内容、顺序、锁、编号或业务语义。
## Out of Scope
缩短编号行持锁时间的重排、其他路由事务的统一治理、部署。
## Acceptance Criteria
- [ ] 三处事务传入 `{ maxWait: 5000, timeout: 10000 }`,采购与报销提交各有回归断言(去掉修复即失败)。
- [ ] 后端全量单测通过。
- [ ] 负载复现不再出现采购提交 P2028。
## Test Plan
- `server/tests/unit/routes/purchase.submit-budget-precheck.test.js`:新增断言提交事务选项;已验证去掉修复时该用例失败。
- `server/tests/unit/routes/reimbursements.expense-standards.test.js`:在既有成功提交用例中断言事务选项。
- 后端全量 vitest;负载复现脚本(见 09-26-approve-success-state-unchanged/research/repro.sh)。
@@ -0,0 +1,33 @@
{
"id": "purchase-submit-tx-timeout",
"name": "purchase-submit-tx-timeout",
"title": "采购提交事务负载下超时返回500",
"description": "负载复现中采购提交交互事务超过Prisma默认5000ms(P2028)返回500;生产b1d25d3c起同写法。查清耗时构成(锁等待/事务内查询),给出最小修复。",
"status": "planning",
"dev_type": null,
"scope": "transaction-timeout",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-09-27",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
"server/src/routes/purchaseRequests.js",
"server/src/routes/reimbursements.js",
"server/tests/unit/routes/purchase.submit-budget-precheck.test.js",
"server/tests/unit/routes/reimbursements.expense-standards.test.js"
],
"notes": "",
"meta": {
"implementationApproval": "APPROVED_BY_USER_20260927"
}
}
+3 -1
查看文件
@@ -1701,6 +1701,8 @@ async function purchaseRequestRoutes(fastify, options = {}) {
throw buildBusinessValidationError("purchase_request.approver_unavailable", { workflow: "no_actionable_approver" }, 409); throw buildBusinessValidationError("purchase_request.approver_unavailable", { workflow: "no_actionable_approver" }, 409);
} }
// ~20 sequential queries plus document numbering; under load this exceeded
// Prisma's 5s interactive-transaction default (P2028 -> 500).
const submitResult = await fastify.prisma.$transaction(async (tx) => { const submitResult = await fastify.prisma.$transaction(async (tx) => {
const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId); const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId);
ensureDraftOwner(request, locked); ensureDraftOwner(request, locked);
@@ -1949,7 +1951,7 @@ async function purchaseRequestRoutes(fastify, options = {}) {
budgetReservation, budgetReservation,
}, },
}; };
}); }, { maxWait: 5000, timeout: 10000 });
void fastify.prisma.auditLog.create({ void fastify.prisma.auditLog.create({
data: buildAuditLogData(request, { data: buildAuditLogData(request, {
+2 -2
查看文件
@@ -1863,7 +1863,7 @@ async function reimbursementRoutes(fastify) {
...created, ...created,
items: createdItems, items: createdItems,
}; };
}); }, { maxWait: 5000, timeout: 10000 });
return sendData(reply, request, { return sendData(reply, request, {
id: reimbursement.id, id: reimbursement.id,
@@ -4498,7 +4498,7 @@ async function reimbursementRoutes(fastify) {
actorId: request.employee.id, actorId: request.employee.id,
}, },
}; };
}); }, { maxWait: 5000, timeout: 10000 });
await finalizeReimbursementSubmitSideEffects({ await finalizeReimbursementSubmitSideEffects({
prisma: fastify.prisma, prisma: fastify.prisma,
@@ -315,6 +315,17 @@ describe("purchase request submit budget precheck gate", () => {
await app.close(); await app.close();
}); });
it("runs the submit transaction with a 10s budget instead of Prisma's 5s default", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck({ budgetHint: { controlMode: "block" } }));
const { app, prisma } = await buildApp({ budgetControlMode: "warn", budgetPrecheckEnabled: false });
const response = await app.inject({ method: "POST", url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/submit` });
expect(response.statusCode).toBe(200);
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { maxWait: 5000, timeout: 10000 });
await app.close();
});
}); });
it.each([1, 2])("submits a stored v%s purchase draft under active policy without reclassifying it", async (version) => { it.each([1, 2])("submits a stored v%s purchase draft under active policy without reclassifying it", async (version) => {
@@ -325,7 +325,7 @@ describe("reimbursements routes expense standard integration", () => {
it.each([true, false])("avoids unused bound-invoice counts: per-item guard=%s", async invoiceGuardEnabled => { it.each([true, false])("avoids unused bound-invoice counts: per-item guard=%s", async invoiceGuardEnabled => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app, tx } = await buildApp({ invoiceGuardEnabled, reimbursementItems: [{ const { app, prisma, tx } = await buildApp({ invoiceGuardEnabled, reimbursementItems: [{
id: "item-no-invoice", amount: "180.00", id: "item-no-invoice", amount: "180.00",
expense_type: { code: "office", category: "office", requires_invoice: invoiceGuardEnabled }, expense_type: { code: "office", category: "office", requires_invoice: invoiceGuardEnabled },
}] }); }] });
@@ -336,6 +336,8 @@ describe("reimbursements routes expense standard integration", () => {
expect(tx.reimbursementItem.findFirst).not.toHaveBeenCalled(); expect(tx.reimbursementItem.findFirst).not.toHaveBeenCalled();
expect(tx.reimbursementItem.aggregate).toHaveBeenCalledTimes(1); expect(tx.reimbursementItem.aggregate).toHaveBeenCalledTimes(1);
expect(tx.workflowHistory.create).toHaveBeenCalled(); expect(tx.workflowHistory.create).toHaveBeenCalled();
// Prisma's 5s interactive-transaction default was exceeded under load (P2028 -> 500).
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { maxWait: 5000, timeout: 10000 });
} finally { await app.close(); } } finally { await app.close(); }
}); });